diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5424ac8..5614cec 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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** - - Do not assume the official docs, guide, or GitHub repositories are fully up-to-date. - - Always prioritize the installed package in `node_modules/discord.js` as the most reliable source. - - Always cross-reference with the provided file `example.ts.txt` to understand new or experimental APIs. +## 1. Primary Source of Truth +- Always treat the **installed package** in `node_modules/discord.js` as the *definitive source* of API behavior and typings. +- Do **not** rely solely on public documentation or examples; cross-verify any methods, classes, or type names directly from: + - The source code and declaration files inside `node_modules/discord.js` + - The project’s internal reference file `example.ts.txt` -2. **Secondary Sources** - - 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. +> If discrepancies exist, assume `example.ts.txt` and local types reflect the *intended experimental API* for this build. -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** - - Always suggest running: - ```bash - 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. +## 2. Secondary Sources +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) -5. **Communication** - - When suggesting code, state explicitly whether it comes from: - - `node_modules` (preferred, authoritative) - - `example.ts.txt` (author-provided experimental reference) - - official docs (secondary, possibly outdated) +> ⚠️ 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 Appwrite’s SDK. + +--- + +## 5. Validation +Before finalizing any generated code or type updates: +- Run TypeScript validation to ensure type correctness: + ```bash + npx tsc --noEmit +``` + +## 6. Communication Protocol +When Copilot suggests or modifies code, it must explicitly indicate the origin of the reference: +- 🟩 node_modules → Authoritative source (preferred) +- 🟦 example.ts.txt → Experimental / confirmed local reference +- 🟨 Official docs → Secondary, possibly outdated source diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3a6ec43..ad6e82a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -12,6 +12,22 @@ "--", "--noEmit" ] + }, + { + "label": "Typecheck: tsc --noEmit", + "type": "shell", + "command": "npm", + "args": [ + "run", + "-s", + "tsc", + "--", + "--noEmit" + ], + "problemMatcher": [ + "$tsc" + ], + "group": "build" } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index fd09b07..0ecbb55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "chrono-node": "2.9.0", "discord-api-types": "0.38.26", "discord.js": "15.0.0-dev.1759363313-f510b5ffa", + "ejs": "^3.1.10", "newrelic": "13.4.0", "node-appwrite": "19.1.0", "pino": "9.13.0", @@ -23,6 +24,7 @@ "redis": "5.8.2" }, "devDependencies": { + "@types/ejs": "^3.1.5", "@types/node": "24.3.1", "ts-node": "10.9.2", "typescript": "5.9.2" @@ -1054,6 +1056,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/luxon": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", @@ -1188,6 +1197,12 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1214,6 +1229,12 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1255,6 +1276,15 @@ "readable-stream": "^3.4.0" } }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -1645,6 +1675,21 @@ "fast-check": "^3.23.1" } }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1788,6 +1833,15 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, "node_modules/find-package-json": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/find-package-json/-/find-package-json-1.2.0.tgz", @@ -2187,6 +2241,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jiti": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", @@ -2377,6 +2448,18 @@ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "license": "ISC" }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -2607,6 +2690,12 @@ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/pino": { "version": "9.13.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.13.0.tgz", diff --git a/package.json b/package.json index 3b88e0a..f4ec7ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amayo", - "version": "2.0.1", + "version": "2.0.15", "description": "", "main": "src/main.ts", "scripts": { @@ -13,6 +13,7 @@ "dev:optimized": "MEMORY_LOG_INTERVAL_SECONDS=300 ENABLE_MEMORY_OPTIMIZER=true NODE_OPTIONS='--expose-gc' npx tsx watch src/main.ts", "start:prod": "NODE_ENV=production NODE_OPTIONS=--max-old-space-size=384 npx tsx src/main.ts", "start:prod-optimized": "NODE_ENV=production ENABLE_MEMORY_OPTIMIZER=true NODE_OPTIONS='--max-old-space-size=512 --expose-gc' npx tsx src/main.ts", + "tsc": "tsc", "typecheck": "tsc --noEmit", "seed:minigames": "tsx src/game/minigames/seed.ts", "start:optimize-relic": "NODE_ENV=production ENABLE_MEMORY_OPTIMIZER=true NEW_RELIC_APP_NAME=amayo NEW_RELIC_LICENSE_KEY=85ef833e676ed6ea726e23b3e373397dFFFFNRAL NODE_OPTIONS='--max-old-space-size=512 --expose-gc' npx tsx --experimental-loader=newrelic/esm-loader.mjs src/main.ts" @@ -29,6 +30,7 @@ "chrono-node": "2.9.0", "discord-api-types": "0.38.26", "discord.js": "15.0.0-dev.1759363313-f510b5ffa", + "ejs": "^3.1.10", "newrelic": "13.4.0", "node-appwrite": "19.1.0", "pino": "9.13.0", @@ -36,6 +38,7 @@ "redis": "5.8.2" }, "devDependencies": { + "@types/ejs": "^3.1.5", "@types/node": "24.3.1", "ts-node": "10.9.2", "typescript": "5.9.2" diff --git a/src/commands/messages/game/_helpers.ts b/src/commands/messages/game/_helpers.ts index ac1c978..f85e587 100644 --- a/src/commands/messages/game/_helpers.ts +++ b/src/commands/messages/game/_helpers.ts @@ -1,4 +1,5 @@ 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 { @@ -209,6 +210,44 @@ export async function fetchItemBasics( return result; } +export type AreaMetadata = + | { + previewImage?: string; + image?: string; + referenceImage?: string; + description?: string; + [k: string]: any; + } + | null + | undefined; + +export function buildAreaMetadataBlocks( + area: Pick +) { + 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; diff --git a/src/commands/messages/game/areaNivel.ts b/src/commands/messages/game/areaNivel.ts index 8f90397..484a4f1 100644 --- a/src/commands/messages/game/areaNivel.ts +++ b/src/commands/messages/game/areaNivel.ts @@ -1,9 +1,18 @@ -import type { CommandMessage } from '../../../core/types/commands'; -import type Amayo from '../../../core/client'; -import { hasManageGuildOrStaff } from '../../../core/lib/permissions'; -import { prisma } from '../../../core/database/prisma'; -import { Message, MessageComponentInteraction, MessageFlags, ButtonInteraction } from 'discord.js'; -import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10'; +import type { CommandMessage } from "../../../core/types/commands"; +import type Amayo from "../../../core/client"; +import { hasManageGuildOrStaff } from "../../../core/lib/permissions"; +import { prisma } from "../../../core/database/prisma"; +import { + Message, + MessageComponentInteraction, + MessageFlags, + ButtonInteraction, +} from "discord.js"; +import { + ComponentType, + TextInputStyle, + ButtonStyle, +} from "discord-api-types/v10"; interface LevelState { areaKey: string; @@ -11,30 +20,52 @@ interface LevelState { requirements?: any; rewards?: any; mobs?: any; + metadata?: any; availableFrom?: string; availableTo?: string; } export const command: CommandMessage = { - name: 'area-nivel', - type: 'message', - aliases: ['nivel-area','arealevel'], + name: "area-nivel", + type: "message", + aliases: ["nivel-area", "arealevel"], cooldown: 10, - description: 'Crea o edita un nivel de una GameArea (requisitos, recompensas, mobs, ventana).', - usage: 'area-nivel ', + description: + "Crea o edita un nivel de una GameArea (requisitos, recompensas, mobs, ventana).", + usage: "area-nivel ", run: async (message, args, _client: Amayo) => { - const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, prisma); - if (!allowed) { await message.reply('❌ No tienes permisos de ManageGuild ni rol de staff.'); return; } + const allowed = await hasManageGuildOrStaff( + message.member, + message.guild!.id, + prisma + ); + if (!allowed) { + await message.reply( + "❌ No tienes permisos de ManageGuild ni rol de staff." + ); + return; + } const areaKey = args[0]?.trim(); - const levelNum = parseInt(args[1] || '', 10); - if (!areaKey || !Number.isFinite(levelNum) || levelNum <= 0) { await message.reply('Uso: `!area-nivel `'); return; } + const levelNum = parseInt(args[1] || "", 10); + if (!areaKey || !Number.isFinite(levelNum) || levelNum <= 0) { + await message.reply("Uso: `!area-nivel `"); + return; + } const guildId = message.guild!.id; - const area = await prisma.gameArea.findFirst({ where: { key: areaKey, OR: [{ guildId }, { guildId: null }] }, orderBy: [{ guildId: 'desc' }] }); - if (!area) { await message.reply('❌ Área no encontrada.'); return; } + const area = await prisma.gameArea.findFirst({ + where: { key: areaKey, OR: [{ guildId }, { guildId: null }] }, + orderBy: [{ guildId: "desc" }], + }); + if (!area) { + await message.reply("❌ Área no encontrada."); + return; + } - const existing = await prisma.gameAreaLevel.findFirst({ where: { areaId: area.id, level: levelNum } }); + const existing = await prisma.gameAreaLevel.findFirst({ + where: { areaId: area.id, level: levelNum }, + }); const state: LevelState = { areaKey, @@ -42,91 +73,264 @@ export const command: CommandMessage = { requirements: existing?.requirements ?? {}, rewards: existing?.rewards ?? {}, mobs: existing?.mobs ?? {}, - availableFrom: existing?.availableFrom ? new Date(existing.availableFrom).toISOString() : '', - availableTo: existing?.availableTo ? new Date(existing.availableTo).toISOString() : '', + metadata: existing?.metadata ?? {}, + availableFrom: existing?.availableFrom + ? new Date(existing.availableFrom).toISOString() + : "", + availableTo: existing?.availableTo + ? new Date(existing.availableTo).toISOString() + : "", }; const editorMsg = await (message.channel as any).send({ - content: `📊 Editor Nivel Área: \`${areaKey}\` nivel ${levelNum} ${existing ? '(editar)' : '(nuevo)'}`, + content: `📊 Editor Nivel Área: \`${areaKey}\` nivel ${levelNum} ${ + existing ? "(editar)" : "(nuevo)" + }`, components: [ - { type: 1, components: [ - { type: 2, style: ButtonStyle.Primary, label: 'Requisitos', custom_id: 'gl_req' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Recompensas', custom_id: 'gl_rewards' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Mobs', custom_id: 'gl_mobs' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Ventana', custom_id: 'gl_window' }, - ] }, - { type: 1, components: [ - { type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'gl_save' }, - { type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'gl_cancel' }, - ] }, + { + type: 1, + components: [ + { + type: 2, + style: ButtonStyle.Primary, + label: "Requisitos", + custom_id: "gl_req", + }, + { + type: 2, + style: ButtonStyle.Secondary, + label: "Recompensas", + custom_id: "gl_rewards", + }, + { + type: 2, + style: ButtonStyle.Secondary, + label: "Mobs", + custom_id: "gl_mobs", + }, + { + type: 2, + style: ButtonStyle.Secondary, + label: "Ventana", + custom_id: "gl_window", + }, + { + type: 2, + style: ButtonStyle.Secondary, + label: "Meta/Imagen", + custom_id: "gl_meta", + }, + ], + }, + { + type: 1, + components: [ + { + type: 2, + style: ButtonStyle.Success, + label: "Guardar", + custom_id: "gl_save", + }, + { + type: 2, + style: ButtonStyle.Danger, + label: "Cancelar", + custom_id: "gl_cancel", + }, + ], + }, ], }); - const collector = editorMsg.createMessageComponentCollector({ time: 30*60_000, filter: (i: MessageComponentInteraction)=> i.user.id === message.author.id }); - collector.on('collect', async (i: MessageComponentInteraction) => { + const collector = editorMsg.createMessageComponentCollector({ + time: 30 * 60_000, + filter: (i: MessageComponentInteraction) => + i.user.id === message.author.id, + }); + collector.on("collect", async (i: MessageComponentInteraction) => { try { if (!i.isButton()) return; switch (i.customId) { - case 'gl_cancel': - await i.deferUpdate(); await editorMsg.edit({ content: '❌ Editor de Nivel cancelado.', components: [] }); collector.stop('cancel'); return; - case 'gl_req': await showJsonModal(i as ButtonInteraction, state, 'requirements', 'Requisitos'); return; - case 'gl_rewards': await showJsonModal(i as ButtonInteraction, state, 'rewards', 'Recompensas'); return; - case 'gl_mobs': await showJsonModal(i as ButtonInteraction, state, 'mobs', 'Mobs'); return; - case 'gl_window': await showWindowModal(i as ButtonInteraction, state); return; - case 'gl_save': + case "gl_cancel": + await i.deferUpdate(); + await editorMsg.edit({ + content: "❌ Editor de Nivel cancelado.", + components: [], + }); + collector.stop("cancel"); + return; + case "gl_req": + await showJsonModal( + i as ButtonInteraction, + state, + "requirements", + "Requisitos" + ); + return; + case "gl_rewards": + await showJsonModal( + i as ButtonInteraction, + state, + "rewards", + "Recompensas" + ); + return; + case "gl_mobs": + await showJsonModal(i as ButtonInteraction, state, "mobs", "Mobs"); + return; + case "gl_window": + await showWindowModal(i as ButtonInteraction, state); + return; + case "gl_meta": + await showJsonModal( + i as ButtonInteraction, + state, + "metadata", + "Metadata (incl. imagen)" + ); + return; + case "gl_save": const data = { areaId: area.id, level: state.level, requirements: state.requirements ?? {}, rewards: state.rewards ?? {}, mobs: state.mobs ?? {}, - availableFrom: state.availableFrom ? new Date(state.availableFrom) : null, - availableTo: state.availableTo ? new Date(state.availableTo) : null, + metadata: state.metadata ?? {}, + availableFrom: state.availableFrom + ? new Date(state.availableFrom) + : null, + availableTo: state.availableTo + ? new Date(state.availableTo) + : null, } as const; if (existing) { - await prisma.gameAreaLevel.update({ where: { id: existing.id }, data }); + await prisma.gameAreaLevel.update({ + where: { id: existing.id }, + data, + }); } else { await prisma.gameAreaLevel.create({ data }); } - await i.reply({ content: '✅ Nivel guardado.', flags: MessageFlags.Ephemeral }); - await editorMsg.edit({ content: `✅ Nivel guardado para \`${areaKey}\` (${state.level}).`, components: [] }); - collector.stop('saved'); + await i.reply({ + content: "✅ Nivel guardado.", + flags: MessageFlags.Ephemeral, + }); + await editorMsg.edit({ + content: `✅ Nivel guardado para \`${areaKey}\` (${state.level}).`, + components: [], + }); + collector.stop("saved"); return; } } catch (e) { - if (!i.deferred && !i.replied) await i.reply({ content: '❌ Error procesando la acción.', flags: MessageFlags.Ephemeral }); + if (!i.deferred && !i.replied) + await i.reply({ + content: "❌ Error procesando la acción.", + flags: MessageFlags.Ephemeral, + }); } }); - collector.on('end', async (_c: any,r: string)=> { if (r==='time') { try { await editorMsg.edit({ content:'⏰ Editor expirado.', components: [] }); } catch {} } }); - } + collector.on("end", async (_c: any, r: string) => { + if (r === "time") { + try { + await editorMsg.edit({ + content: "⏰ Editor expirado.", + components: [], + }); + } catch {} + } + }); + }, }; -async function showJsonModal(i: ButtonInteraction, state: LevelState, field: 'requirements'|'rewards'|'mobs', title: string) { +async function showJsonModal( + i: ButtonInteraction, + state: LevelState, + field: "requirements" | "rewards" | "mobs" | "metadata", + title: string +) { const current = JSON.stringify(state[field] ?? {}); - const modal = { title, customId: `gl_json_${field}`, components: [ - { type: ComponentType.Label, label: 'JSON', component: { type: ComponentType.TextInput, customId: 'json', style: TextInputStyle.Paragraph, required: false, value: current.slice(0,4000) } }, - ] } as const; + const modal = { + title, + customId: `gl_json_${field}`, + components: [ + { + type: ComponentType.Label, + label: "JSON", + component: { + type: ComponentType.TextInput, + customId: "json", + style: TextInputStyle.Paragraph, + required: false, + value: current.slice(0, 4000), + }, + }, + ], + } as const; await i.showModal(modal); try { const sub = await i.awaitModalSubmit({ time: 300_000 }); - const raw = sub.components.getTextInputValue('json'); - if (raw) { try { state[field] = JSON.parse(raw); await sub.reply({ content: '✅ Guardado.', flags: MessageFlags.Ephemeral }); } catch { await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral }); } } - else { state[field] = {}; await sub.reply({ content: 'ℹ️ Limpio.', flags: MessageFlags.Ephemeral }); } + const raw = sub.components.getTextInputValue("json"); + if (raw) { + try { + state[field] = JSON.parse(raw); + await sub.reply({ + content: "✅ Guardado.", + flags: MessageFlags.Ephemeral, + }); + } catch { + await sub.reply({ + content: "❌ JSON inválido.", + flags: MessageFlags.Ephemeral, + }); + } + } else { + state[field] = {}; + await sub.reply({ content: "ℹ️ Limpio.", flags: MessageFlags.Ephemeral }); + } } catch {} } async function showWindowModal(i: ButtonInteraction, state: LevelState) { - const modal = { title: 'Ventana del Nivel', customId: 'gl_window_modal', components: [ - { type: ComponentType.Label, label: 'Desde (ISO, opcional)', component: { type: ComponentType.TextInput, customId: 'from', style: TextInputStyle.Short, required: false, value: state.availableFrom ?? '' } }, - { type: ComponentType.Label, label: 'Hasta (ISO, opcional)', component: { type: ComponentType.TextInput, customId: 'to', style: TextInputStyle.Short, required: false, value: state.availableTo ?? '' } }, - ] } as const; + const modal = { + title: "Ventana del Nivel", + customId: "gl_window_modal", + components: [ + { + type: ComponentType.Label, + label: "Desde (ISO, opcional)", + component: { + type: ComponentType.TextInput, + customId: "from", + style: TextInputStyle.Short, + required: false, + value: state.availableFrom ?? "", + }, + }, + { + type: ComponentType.Label, + label: "Hasta (ISO, opcional)", + component: { + type: ComponentType.TextInput, + customId: "to", + style: TextInputStyle.Short, + required: false, + value: state.availableTo ?? "", + }, + }, + ], + } as const; await i.showModal(modal); try { const sub = await i.awaitModalSubmit({ time: 300_000 }); - const from = sub.components.getTextInputValue('from').trim(); - const to = sub.components.getTextInputValue('to').trim(); - state.availableFrom = from || ''; - state.availableTo = to || ''; - await sub.reply({ content: '✅ Ventana actualizada.', flags: MessageFlags.Ephemeral }); + const from = sub.components.getTextInputValue("from").trim(); + const to = sub.components.getTextInputValue("to").trim(); + state.availableFrom = from || ""; + state.availableTo = to || ""; + await sub.reply({ + content: "✅ Ventana actualizada.", + flags: MessageFlags.Ephemeral, + }); } catch {} } diff --git a/src/commands/messages/game/mina.ts b/src/commands/messages/game/mina.ts index 0fa0b33..1a9d777 100644 --- a/src/commands/messages/game/mina.ts +++ b/src/commands/messages/game/mina.ts @@ -1,21 +1,36 @@ -import type { CommandMessage } from '../../../core/types/commands'; -import type Amayo from '../../../core/client'; -import { runMinigame } from '../../../game/minigames/service'; -import { getDefaultLevel, findBestToolKey, parseGameArgs, 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 type { CommandMessage } from "../../../core/types/commands"; +import type Amayo from "../../../core/client"; +import { runMinigame } from "../../../game/minigames/service"; +import { + getDefaultLevel, + findBestToolKey, + parseGameArgs, + 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 = { - name: 'mina', - type: 'message', - aliases: ['minar'], + name: "mina", + type: "message", + aliases: ["minar"], cooldown: 5, - description: '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)', + description: + "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) => { const userId = message.author.id; const guildId = message.guild!.id; @@ -23,59 +38,89 @@ export const command: CommandMessage = { const areaInfo = areaOverride ? await resolveGuildAreaWithFallback(guildId, areaOverride) - : await resolveAreaByType(guildId, 'MINE'); + : await resolveAreaByType(guildId, "MINE"); if (!areaInfo.area) { 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 { - await message.reply('⚠️ No hay un área de tipo **MINE** configurada. Crea una con `!area-crear` o especifica `area:`.'); + await message.reply( + "⚠️ No hay un área de tipo **MINE** configurada. Crea una con `!area-crear` o especifica `area:`." + ); } return; } const { area, source } = areaInfo; - const globalNotice = source === 'global' - ? `ℹ️ Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **MINE** para personalizarla en este servidor.` - : null; + const globalNotice = + source === "global" + ? `ℹ️ Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **MINE** para personalizarla en este servidor.` + : null; - const level = levelArg ?? await getDefaultLevel(userId, guildId, area.id); - const toolKey = providedTool ?? await findBestToolKey(userId, guildId, 'pickaxe'); + const level = levelArg ?? (await getDefaultLevel(userId, guildId, area.id)); + const toolKey = + providedTool ?? (await findBestToolKey(userId, guildId, "pickaxe")); 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 - .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!); if (result.tool?.key) rewardKeys.push(result.tool.key); const rewardItems = await fetchItemBasics(guildId, rewardKeys); - + // Actualizar stats await updateStats(userId, guildId, { minesCompleted: 1 }); // Actualizar progreso de misiones - await updateQuestProgress(userId, guildId, 'mine_count', 1); - - // Verificar logros - const newAchievements = await checkAchievements(userId, guildId, 'mine_count'); - - const rewardLines = result.rewards.length - ? result.rewards.map((r) => { - if (r.type === 'coins') return `• 🪙 +${r.amount}`; - const info = rewardItems.get(r.itemKey!); - const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); - return `• ${label} x${r.qty ?? 1}`; - }).join('\n') - : '• —'; - const mobsLines = result.mobs.length - ? result.mobs.map(m => `• ${m}`).join('\n') - : '• —'; - 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.)`}` - : '—'; + await updateQuestProgress(userId, guildId, "mine_count", 1); - const blocks = [textBlock('# ⛏️ Mina')]; + // Verificar logros + const newAchievements = await checkAchievements( + userId, + guildId, + "mine_count" + ); + + const rewardLines = result.rewards.length + ? result.rewards + .map((r) => { + if (r.type === "coins") return `• 🪙 +${r.amount}`; + const info = rewardItems.get(r.itemKey!); + const label = formatItemLabel( + info ?? { key: r.itemKey!, name: null, icon: null } + ); + return `• ${label} x${r.qty ?? 1}`; + }) + .join("\n") + : "• —"; + const mobsLines = result.mobs.length + ? result.mobs.map((m) => `• ${m}`).join("\n") + : "• —"; + 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.)` + }` + : "—"; + + const blocks = [textBlock("# ⛏️ Mina")]; if (globalNotice) { blocks.push(dividerBlock({ divider: false, spacing: 1 })); @@ -83,16 +128,32 @@ export const command: CommandMessage = { } blocks.push(dividerBlock()); - const areaScope = source === 'global' ? '🌐 Configuración global' : '📍 Configuración local'; - blocks.push(textBlock(`**Área:** \`${area.key}\` • ${areaScope}\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`)); + const areaScope = + 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(textBlock(`**Recompensas**\n${rewardLines}`)); blocks.push(dividerBlock({ divider: false, spacing: 1 })); 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) { 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}`)); } @@ -101,6 +162,5 @@ export const command: CommandMessage = { } catch (e: any) { await message.reply(`❌ No se pudo minar: ${e?.message ?? e}`); } - } + }, }; - diff --git a/src/commands/messages/game/pelear.ts b/src/commands/messages/game/pelear.ts index b3afe2c..4b6d04e 100644 --- a/src/commands/messages/game/pelear.ts +++ b/src/commands/messages/game/pelear.ts @@ -1,21 +1,36 @@ -import type { CommandMessage } from '../../../core/types/commands'; -import type Amayo from '../../../core/client'; -import { runMinigame } from '../../../game/minigames/service'; -import { getDefaultLevel, findBestToolKey, parseGameArgs, 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 type { CommandMessage } from "../../../core/types/commands"; +import type Amayo from "../../../core/client"; +import { runMinigame } from "../../../game/minigames/service"; +import { + getDefaultLevel, + findBestToolKey, + parseGameArgs, + 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 = { - name: 'pelear', - type: 'message', - aliases: ['fight','arena'], + name: "pelear", + type: "message", + aliases: ["fight", "arena"], cooldown: 8, - description: 'Entra a la arena y pelea (usa espada si está disponible).', - usage: 'pelear [nivel] [toolKey] [area:clave] (ej: pelear 1 weapon.sword.iron)', + description: "Entra a la arena y pelea (usa espada si está disponible).", + usage: + "pelear [nivel] [toolKey] [area:clave] (ej: pelear 1 weapon.sword.iron)", run: async (message, args, _client: Amayo) => { const userId = message.author.id; const guildId = message.guild!.id; @@ -23,63 +38,98 @@ export const command: CommandMessage = { const areaInfo = areaOverride ? await resolveGuildAreaWithFallback(guildId, areaOverride) - : await resolveAreaByType(guildId, 'FIGHT'); + : await resolveAreaByType(guildId, "FIGHT"); if (!areaInfo.area) { 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 { - await message.reply('⚠️ No hay un área de tipo **FIGHT** configurada. Crea una con `!area-crear` o especifica `area:`.'); + await message.reply( + "⚠️ No hay un área de tipo **FIGHT** configurada. Crea una con `!area-crear` o especifica `area:`." + ); } return; } const { area, source } = areaInfo; - const globalNotice = source === 'global' - ? `ℹ️ Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **FIGHT** para personalizarla en este servidor.` - : null; + const globalNotice = + source === "global" + ? `ℹ️ Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **FIGHT** para personalizarla en este servidor.` + : null; - const level = levelArg ?? await getDefaultLevel(userId, guildId, area.id); - const toolKey = providedTool ?? await findBestToolKey(userId, guildId, 'sword'); + const level = levelArg ?? (await getDefaultLevel(userId, guildId, area.id)); + const toolKey = + providedTool ?? (await findBestToolKey(userId, guildId, "sword")); 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 - .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!); if (result.tool?.key) rewardKeys.push(result.tool.key); const rewardItems = await fetchItemBasics(guildId, rewardKeys); // Actualizar stats y misiones await updateStats(userId, guildId, { fightsCompleted: 1 }); - await updateQuestProgress(userId, guildId, 'fight_count', 1); - + await updateQuestProgress(userId, guildId, "fight_count", 1); + // Contar mobs derrotados const mobsCount = result.mobs.length; if (mobsCount > 0) { 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 rewardLines = result.rewards.length - ? result.rewards.map((r) => { - if (r.type === 'coins') return `• 🪙 +${r.amount}`; - const info = rewardItems.get(r.itemKey!); - const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); - return `• ${label} x${r.qty ?? 1}`; - }).join('\n') - : '• —'; - const mobsLines = result.mobs.length - ? result.mobs.map(m => `• ${m}`).join('\n') - : '• —'; - 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.)`}` - : '—'; - const blocks = [textBlock('# ⚔️ Arena')]; + const newAchievements = await checkAchievements( + userId, + guildId, + "fight_count" + ); + + const rewardLines = result.rewards.length + ? result.rewards + .map((r) => { + if (r.type === "coins") return `• 🪙 +${r.amount}`; + const info = rewardItems.get(r.itemKey!); + const label = formatItemLabel( + info ?? { key: r.itemKey!, name: null, icon: null } + ); + return `• ${label} x${r.qty ?? 1}`; + }) + .join("\n") + : "• —"; + const mobsLines = result.mobs.length + ? result.mobs.map((m) => `• ${m}`).join("\n") + : "• —"; + 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.)` + }` + : "—"; + + const blocks = [textBlock("# ⚔️ Arena")]; if (globalNotice) { blocks.push(dividerBlock({ divider: false, spacing: 1 })); @@ -87,16 +137,32 @@ export const command: CommandMessage = { } blocks.push(dividerBlock()); - const areaScope = source === 'global' ? '🌐 Configuración global' : '📍 Configuración local'; - blocks.push(textBlock(`**Área:** \`${area.key}\` • ${areaScope}\n**Nivel:** ${level}\n**Arma:** ${toolInfo}`)); + const areaScope = + 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(textBlock(`**Recompensas**\n${rewardLines}`)); blocks.push(dividerBlock({ divider: false, spacing: 1 })); 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) { 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}`)); } @@ -105,6 +171,5 @@ export const command: CommandMessage = { } catch (e: any) { await message.reply(`❌ No se pudo pelear: ${e?.message ?? e}`); } - } + }, }; - diff --git a/src/commands/messages/game/pescar.ts b/src/commands/messages/game/pescar.ts index bacdd10..6b8e7c7 100644 --- a/src/commands/messages/game/pescar.ts +++ b/src/commands/messages/game/pescar.ts @@ -1,21 +1,36 @@ -import type { CommandMessage } from '../../../core/types/commands'; -import type Amayo from '../../../core/client'; -import { runMinigame } from '../../../game/minigames/service'; -import { getDefaultLevel, findBestToolKey, parseGameArgs, 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 type { CommandMessage } from "../../../core/types/commands"; +import type Amayo from "../../../core/client"; +import { runMinigame } from "../../../game/minigames/service"; +import { + getDefaultLevel, + findBestToolKey, + parseGameArgs, + 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 = { - name: 'pescar', - type: 'message', - aliases: ['fish'], + name: "pescar", + type: "message", + aliases: ["fish"], cooldown: 5, - description: '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)', + description: + "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) => { const userId = message.author.id; const guildId = message.guild!.id; @@ -23,55 +38,85 @@ export const command: CommandMessage = { const areaInfo = areaOverride ? await resolveGuildAreaWithFallback(guildId, areaOverride) - : await resolveAreaByType(guildId, 'LAGOON'); + : await resolveAreaByType(guildId, "LAGOON"); if (!areaInfo.area) { 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 { - await message.reply('⚠️ No hay un área de tipo **LAGOON** configurada. Crea una con `!area-crear` o especifica `area:`.'); + await message.reply( + "⚠️ No hay un área de tipo **LAGOON** configurada. Crea una con `!area-crear` o especifica `area:`." + ); } return; } const { area, source } = areaInfo; - const globalNotice = source === 'global' - ? `ℹ️ Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **LAGOON** para personalizarla en este servidor.` - : null; + const globalNotice = + source === "global" + ? `ℹ️ Usando configuración global para \`${area.key}\`. Puedes crear \`gameArea\` tipo **LAGOON** para personalizarla en este servidor.` + : null; - const level = levelArg ?? await getDefaultLevel(userId, guildId, area.id); - const toolKey = providedTool ?? await findBestToolKey(userId, guildId, 'rod'); + const level = levelArg ?? (await getDefaultLevel(userId, guildId, area.id)); + const toolKey = + providedTool ?? (await findBestToolKey(userId, guildId, "rod")); 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 - .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!); if (result.tool?.key) rewardKeys.push(result.tool.key); const rewardItems = await fetchItemBasics(guildId, rewardKeys); // Actualizar stats y misiones await updateStats(userId, guildId, { fishingCompleted: 1 }); - await updateQuestProgress(userId, guildId, 'fish_count', 1); - const newAchievements = await checkAchievements(userId, guildId, 'fish_count'); + await updateQuestProgress(userId, guildId, "fish_count", 1); + const newAchievements = await checkAchievements( + userId, + guildId, + "fish_count" + ); const rewardLines = result.rewards.length - ? result.rewards.map((r) => { - if (r.type === 'coins') return `• 🪙 +${r.amount}`; - const info = rewardItems.get(r.itemKey!); - const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); - return `• ${label} x${r.qty ?? 1}`; - }).join('\n') - : '• —'; + ? result.rewards + .map((r) => { + if (r.type === "coins") return `• 🪙 +${r.amount}`; + const info = rewardItems.get(r.itemKey!); + const label = formatItemLabel( + info ?? { key: r.itemKey!, name: null, icon: null } + ); + return `• ${label} x${r.qty ?? 1}`; + }) + .join("\n") + : "• —"; const mobsLines = result.mobs.length - ? result.mobs.map(m => `• ${m}`).join('\n') - : '• —'; + ? result.mobs.map((m) => `• ${m}`).join("\n") + : "• —"; 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) { blocks.push(dividerBlock({ divider: false, spacing: 1 })); @@ -79,16 +124,32 @@ export const command: CommandMessage = { } blocks.push(dividerBlock()); - const areaScope = source === 'global' ? '🌐 Configuración global' : '📍 Configuración local'; - blocks.push(textBlock(`**Área:** \`${area.key}\` • ${areaScope}\n**Nivel:** ${level}\n**Herramienta:** ${toolInfo}`)); + const areaScope = + 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(textBlock(`**Recompensas**\n${rewardLines}`)); blocks.push(dividerBlock({ divider: false, spacing: 1 })); 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) { 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}`)); } @@ -97,6 +158,5 @@ export const command: CommandMessage = { } catch (e: any) { await message.reply(`❌ No se pudo pescar: ${e?.message ?? e}`); } - } + }, }; - diff --git a/src/commands/messages/game/plantar.ts b/src/commands/messages/game/plantar.ts index 915752c..6eafac1 100644 --- a/src/commands/messages/game/plantar.ts +++ b/src/commands/messages/game/plantar.ts @@ -1,71 +1,119 @@ -import type { CommandMessage } from '../../../core/types/commands'; -import type Amayo from '../../../core/client'; -import { runMinigame } from '../../../game/minigames/service'; -import { resolveArea, getDefaultLevel, findBestToolKey, sendDisplayReply, fetchItemBasics, formatItemLabel } from './_helpers'; -import { buildDisplay, dividerBlock, textBlock } from '../../../core/lib/componentsV2'; +import type { CommandMessage } from "../../../core/types/commands"; +import type Amayo from "../../../core/client"; +import { runMinigame } from "../../../game/minigames/service"; +import { + 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 = { - name: 'plantar', - type: 'message', - aliases: ['farm'], + name: "plantar", + type: "message", + aliases: ["farm"], cooldown: 5, - description: 'Planta/cosecha en el campo (usa azada si está disponible).', - usage: 'plantar [nivel] [toolKey] (ej: plantar 1 tool.hoe.basic)', + description: "Planta/cosecha en el campo (usa azada si está disponible).", + usage: "plantar [nivel] [toolKey] (ej: plantar 1 tool.hoe.basic)", run: async (message, args, _client: Amayo) => { const userId = message.author.id; const guildId = message.guild!.id; - const areaKey = 'farm.field'; + const areaKey = "farm.field"; 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 level = levelArg ?? await getDefaultLevel(userId, guildId, area.id); - const toolKey = providedTool ?? await findBestToolKey(userId, guildId, 'hoe'); + const level = levelArg ?? (await getDefaultLevel(userId, guildId, area.id)); + const toolKey = + providedTool ?? (await findBestToolKey(userId, guildId, "hoe")); 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 - .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!); if (result.tool?.key) rewardKeys.push(result.tool.key); const rewardItems = await fetchItemBasics(guildId, rewardKeys); const rewardLines = result.rewards.length - ? result.rewards.map((r) => { - if (r.type === 'coins') return `• 🪙 +${r.amount}`; - const info = rewardItems.get(r.itemKey!); - const label = formatItemLabel(info ?? { key: r.itemKey!, name: null, icon: null }); - return `• ${label} x${r.qty ?? 1}`; - }).join('\n') - : '• —'; + ? result.rewards + .map((r) => { + if (r.type === "coins") return `• 🪙 +${r.amount}`; + const info = rewardItems.get(r.itemKey!); + const label = formatItemLabel( + info ?? { key: r.itemKey!, name: null, icon: null } + ); + return `• ${label} x${r.qty ?? 1}`; + }) + .join("\n") + : "• —"; const mobsLines = result.mobs.length - ? result.mobs.map(m => `• ${m}`).join('\n') - : '• —'; + ? result.mobs.map((m) => `• ${m}`).join("\n") + : "• —"; 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('# 🌱 Campo'), + textBlock("# 🌱 Campo"), 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 }), textBlock(`**Recompensas**\n${rewardLines}`), dividerBlock({ divider: false, spacing: 1 }), 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); await sendDisplayReply(message, display); } catch (e: any) { await message.reply(`❌ No se pudo plantar: ${e?.message ?? e}`); } - } + }, }; - diff --git a/src/core/api/appwrite.ts b/src/core/api/appwrite.ts index 759b1ef..89f70eb 100644 --- a/src/core/api/appwrite.ts +++ b/src/core/api/appwrite.ts @@ -1,6 +1,6 @@ // Simple Appwrite client wrapper // @ts-ignore -import { Client, Databases } from "node-appwrite"; +import { Client, Databases, Storage } from "node-appwrite"; const endpoint = process.env.APPWRITE_ENDPOINT || ""; 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 = 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 databases: Databases | null = null; +let storage: Storage | null = null; function ensureClient() { if (!endpoint || !projectId || !apiKey) return null; @@ -25,6 +38,7 @@ function ensureClient() { .setProject(projectId) .setKey(apiKey); databases = new Databases(client); + storage = new Storage(client); return client; } @@ -32,6 +46,10 @@ export function getDatabases(): Databases | null { return ensureClient() ? (databases as Databases) : null; } +export function getStorage(): Storage | null { + return ensureClient() ? (storage as Storage) : null; +} + export function isAppwriteConfigured(): boolean { return Boolean( endpoint && @@ -61,3 +79,20 @@ export function isGuildCacheConfigured(): boolean { 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) + ); +} diff --git a/src/core/lib/componentsV2.ts b/src/core/lib/componentsV2.ts index 0d7c7ea..4e5bb9f 100644 --- a/src/core/lib/componentsV2.ts +++ b/src/core/lib/componentsV2.ts @@ -1,11 +1,17 @@ export type DisplayBlock = - | { kind: 'text'; content: string } - | { kind: 'divider'; divider?: boolean; spacing?: number }; + | { kind: "text"; content: string } + | { 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 => ({ - kind: 'divider', +export const dividerBlock = ( + options: { divider?: boolean; spacing?: number } = {} +): DisplayBlock => ({ + kind: "divider", divider: options.divider, spacing: options.spacing, }); @@ -15,10 +21,15 @@ export function buildDisplay(accentColor: number, blocks: DisplayBlock[]) { type: 17 as const, accent_color: accentColor, components: blocks.map((block) => { - if (block.kind === 'text') { + if (block.kind === "text") { 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 { type: 14 as const, divider: block.divider ?? true, diff --git a/src/server/public/assets/css/styles.css b/src/server/public/assets/css/styles.css index 77759ef..faca8c0 100644 --- a/src/server/public/assets/css/styles.css +++ b/src/server/public/assets/css/styles.css @@ -23,6 +23,29 @@ body { animation: glow 3s ease-in-out infinite; } +/* Code blocks enhancements */ +.code-block pre { + background: transparent !important; + margin: 0; + padding: 0; +} + +.code-block pre code { + display: block; + padding: 1rem 1.25rem; + font-size: 0.9rem; + line-height: 1.5; +} + +/* Etiquetas visuales API/CLI (apoyadas por header dinámico) */ +.code-block .is-api { + background: linear-gradient(90deg, rgba(99,102,241,0.15), rgba(147,51,234,0.15)); +} + +.code-block .is-cli { + background: linear-gradient(90deg, rgba(236,72,153,0.15), rgba(59,130,246,0.15)); +} + /* Scrollbar personalizado (opcional, moderno) */ ::-webkit-scrollbar { width: 8px; diff --git a/src/server/public/assets/js/code.js b/src/server/public/assets/js/code.js new file mode 100644 index 0000000..a5a52fb --- /dev/null +++ b/src/server/public/assets/js/code.js @@ -0,0 +1,86 @@ +// Mejora de bloques de código: resaltar, barra de acciones (copiar), y etiquetas API/CLI +(function () { + function enhanceCodeBlocks() { + const pres = document.querySelectorAll('pre'); + pres.forEach((pre) => { + if (pre.dataset.enhanced === '1') return; + pre.dataset.enhanced = '1'; + + // Asegurar que exista + let codeEl = pre.querySelector('code'); + const textRaw = pre.textContent || ''; + if (!codeEl) { + codeEl = document.createElement('code'); + codeEl.textContent = textRaw; + const t = textRaw.trimStart(); + const isJSON = t.startsWith('{') || t.startsWith('['); + const isCLI = /^(!|\$|curl\s|#)/m.test(t); + if (isJSON) codeEl.className = 'language-json'; + else if (isCLI) codeEl.className = 'language-bash'; + else codeEl.className = 'language-text'; + pre.textContent = ''; + pre.appendChild(codeEl); + } + + const wrapper = document.createElement('div'); + wrapper.className = 'code-block group relative overflow-hidden rounded-xl border border-white/10 bg-slate-900/70 shadow-lg animate-[slideIn_0.4s_ease-out]'; + + const header = document.createElement('div'); + header.className = 'flex items-center justify-between px-3 py-2 text-xs bg-slate-800/60 border-b border-white/10'; + + const label = document.createElement('span'); + label.className = 'font-mono tracking-wide text-slate-300'; + + const lang = (codeEl.className || '').toLowerCase(); + let kind = ''; + if (/bash|shell|sh/.test(lang)) kind = 'CLI'; + else if (/json|typescript|javascript/.test(lang)) kind = 'API'; + label.textContent = kind || (lang.replace('language-', '').toUpperCase() || 'CODE'); + if (kind === 'API') header.classList.add('is-api'); + else if (kind === 'CLI') header.classList.add('is-cli'); + + const actions = document.createElement('div'); + actions.className = 'flex items-center gap-1'; + + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'rounded-md px-2 py-1 text-slate-300 hover:text-white hover:bg-white/10 transition'; + copyBtn.textContent = 'Copiar'; + copyBtn.addEventListener('click', async () => { + try { + const text = codeEl.innerText || pre.innerText || ''; + await navigator.clipboard.writeText(text); + copyBtn.textContent = 'Copiado!'; + setTimeout(() => (copyBtn.textContent = 'Copiar'), 1200); + } catch (err) { + // silencioso + } + }); + + header.appendChild(label); + header.appendChild(actions); + actions.appendChild(copyBtn); + + const content = document.createElement('div'); + content.className = 'relative'; + if (pre.parentNode) pre.parentNode.insertBefore(wrapper, pre); + wrapper.appendChild(header); + wrapper.appendChild(content); + content.appendChild(pre); + + pre.classList.add('overflow-auto'); + }); + + if (window.hljs) { + document.querySelectorAll('pre code').forEach((el) => { + window.hljs.highlightElement(el); + }); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', enhanceCodeBlocks); + } else { + enhanceCodeBlocks(); + } +})(); diff --git a/src/server/public/index_modern_test.html b/src/server/public/index_modern_test.html deleted file mode 100644 index 2f4feca..0000000 --- a/src/server/public/index_modern_test.html +++ /dev/null @@ -1,210 +0,0 @@ - - - - - - Amayo Bot | Guía Completa - Diseño Moderno - - - - - - - - -
-
-
-
-
- -
- -
-
- -
-
- -
- - - - - - Amayo Bot • v0.11.20 - -
- - -

- - Guía Completa - - - Amayo Bot - -

- - -

- Sistema completo de economía, minijuegos, misiones y IA para Discord -

- - -
- -
-
- - Comenzar ahora - - - - -
- - -
- - -
- - Discord.js 15.0-dev - - - Enero 2025 - - - 23 Secciones Completas - -
-
-
-
- - -
-
-
-
-
-

- 🚀 Primeros Pasos -

-
-
-
- -

- ¡Bienvenido a Amayo Bot! Este bot transforma tu servidor en una experiencia de juego completa. -

- - -
-

- - Características Principales -

-
-
- 🎮 -
-

Minijuegos Épicos

-

Mina, pesca, pelea y cultiva recursos

-
-
-
- 💰 -
-

Economía Completa

-

Tienda, crafteo e inventario

-
-
-
- 🏆 -
-

Progresión & Logros

-

Misiones, logros y rachas diarias

-
-
-
-
- - -
-
-
- -

Prefix: !

-
-

Personalizable con !configuracion

-
- -
-
- -

Ayuda

-
-

Usa !ayuda para ver comandos

-
-
-
- - -
-

✨ Este es el nuevo diseño ultra moderno ✨

-

¿Te gusta este estilo? Todas las 23 secciones tendrán este diseño con:

-
- Glassmorphism - Hover Effects - Gradientes - Animaciones - 100% Tailwind -
-
-
-
-
- - - - diff --git a/src/server/server.ts b/src/server/server.ts index e4abfb0..cdb6365 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,8 +1,27 @@ import { createServer, IncomingMessage, ServerResponse } from "node:http"; import { promises as fs } from "node:fs"; +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; import path from "node:path"; +import ejs from "ejs"; const publicDir = path.join(__dirname, "public"); +const viewsDir = path.join(__dirname, "views"); +// Compresión síncrona (rápida para tamaños pequeños de HTML/CSS/JS) + +// Cargar metadatos del proyecto para usarlos como variables en las vistas +let pkg: { + name?: string; + version?: string; + dependencies?: Record; +} = {}; +try { + const pkgPath = path.join(__dirname, "../../package.json"); + pkg = JSON.parse(readFileSync(pkgPath, "utf8")); +} catch { + // Ignorar si no se puede leer; usaremos valores por defecto +} const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -21,6 +40,104 @@ const MIME_TYPES: Record = { const PORT = Number(process.env.PORT) || 3000; +// --- Basic hardening: blocklist patterns and lightweight rate limiting for suspicious paths --- +const BLOCKED_PATTERNS: RegExp[] = [ + /\b(npci|upi|bhim|aadhaar|aadhar|cts|fastag|bbps|rgcs|nuup|apbs|hdfc|ergo|securities|banking|insurance)\b/i, +]; + +const SUSP_LENGTH = 18; // long single-segment slugs tend to be bot probes +const RATE_WINDOW_MS = 60_000; // 1 minute window +const RATE_MAX_SUSPICIOUS = 20; // allow up to 20 suspicious hits per minute per IP + +type Counter = { count: number; resetAt: number }; +const suspiciousCounters = new Map(); + +function getClientIp(req: IncomingMessage): string { + const cf = (req.headers["cf-connecting-ip"] as string) || ""; + const xff = (req.headers["x-forwarded-for"] as string) || ""; + const chain = (cf || xff) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return ( + chain[0] || (req.socket && (req.socket as any).remoteAddress) || "unknown" + ); +} + +function isSingleSegment(pathname: string) { + // e.g. /foo-bar, no additional slashes + return /^\/[A-Za-z0-9._-]+$/.test(pathname); +} + +function isSuspiciousPath(pathname: string): boolean { + if ( + !pathname || + pathname === "/" || + pathname === "/index" || + pathname === "/index.html" + ) + return false; + if (BLOCKED_PATTERNS.some((re) => re.test(pathname))) return true; + if (isSingleSegment(pathname) && pathname.length > SUSP_LENGTH) return true; + return false; +} + +function hitSuspicious(ip: string): { + allowed: boolean; + resetIn: number; + remaining: number; +} { + const now = Date.now(); + let bucket = suspiciousCounters.get(ip); + if (!bucket || now >= bucket.resetAt) { + bucket = { count: 0, resetAt: now + RATE_WINDOW_MS }; + } + bucket.count += 1; + suspiciousCounters.set(ip, bucket); + const allowed = bucket.count <= RATE_MAX_SUSPICIOUS; + return { + allowed, + resetIn: Math.max(0, bucket.resetAt - now), + remaining: Math.max(0, RATE_MAX_SUSPICIOUS - bucket.count), + }; +} + +function applySecurityHeaders(base: Record = {}) { + return { + "Strict-Transport-Security": "max-age=15552000; includeSubDomains; preload", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "X-Frame-Options": "DENY", + // Mild CSP to avoid breaking inline styles/scripts already present; adjust as needed + "Content-Security-Policy": + "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; script-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; frame-src 'self' https://ko-fi.com https://*.ko-fi.com; child-src 'self' https://ko-fi.com https://*.ko-fi.com", + ...base, + }; +} + +function computeEtag(buf: Buffer): string { + // Weak ETag derived from content sha1 and length + const hash = createHash("sha1").update(buf).digest("base64"); + return `W/"${buf.length.toString(16)}-${hash}"`; +} + +function acceptsEncoding(req: IncomingMessage, enc: string): boolean { + const ae = (req.headers["accept-encoding"] as string) || ""; + return ae + .split(",") + .map((s) => s.trim()) + .includes(enc); +} + +function shouldCompress(mime: string): boolean { + return ( + mime.startsWith("text/") || + mime.includes("json") || + mime.includes("javascript") || + mime.includes("svg") + ); +} + const resolvePath = (pathname: string): string => { const decoded = decodeURIComponent(pathname); let target = decoded; @@ -37,6 +154,7 @@ const resolvePath = (pathname: string): string => { }; const sendResponse = async ( + req: IncomingMessage, res: ServerResponse, filePath: string, statusCode = 200 @@ -47,59 +165,228 @@ const sendResponse = async ( ? "no-cache" : "public, max-age=86400, immutable"; + const stat = await fs.stat(filePath).catch(() => undefined); const data = await fs.readFile(filePath); - res.writeHead(statusCode, { + const etag = computeEtag(data); + + // Conditional requests + const inm = (req.headers["if-none-match"] as string) || ""; + if (inm && inm === etag) { + res.writeHead( + 304, + applySecurityHeaders({ + ETag: etag, + "Cache-Control": cacheControl, + ...(stat ? { "Last-Modified": stat.mtime.toUTCString() } : {}), + }) + ); + res.end(); + return; + } + + let body: any = data; + const headers: Record = { "Content-Type": mimeType, "Cache-Control": cacheControl, - }); - res.end(data); + ETag: etag, + ...(stat ? { "Last-Modified": stat.mtime.toUTCString() } : {}), + }; + + if (shouldCompress(mimeType) && acceptsEncoding(req, "gzip")) { + try { + body = gzipSync(data); + headers["Content-Encoding"] = "gzip"; + headers["Vary"] = "Accept-Encoding"; + } catch { + // Si falla compresión, enviar sin comprimir + } + } + + res.writeHead(statusCode, applySecurityHeaders(headers)); + res.end(body); }; -export const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - try { - // 🔒 Forzar HTTPS en producción (Heroku) - if (process.env.NODE_ENV === "production") { - const proto = req.headers["x-forwarded-proto"]; - if (proto && proto !== "https") { - res.writeHead(301, { - Location: `https://${req.headers.host}${req.url}`, - }); - return res.end(); - } - } +const renderTemplate = async ( + req: IncomingMessage, + res: ServerResponse, + template: string, + locals: Record = {}, + statusCode = 200 +) => { + const pageFile = path.join(viewsDir, "pages", `${template}.ejs`); + const layoutFile = path.join(viewsDir, "layouts", "layout.ejs"); + const pageBody = await ejs.renderFile(pageFile, locals, { async: true }); + const defaultTitle = `${ + locals.appName ?? pkg.name ?? "Amayo Bot" + } | Guía Completa`; + const html = await ejs.renderFile( + layoutFile, + { + head: null, + scripts: null, + ...locals, + title: locals.title ?? defaultTitle, + body: pageBody, + }, + { async: true } + ); + const htmlBuffer = Buffer.from(html, "utf8"); + const etag = computeEtag(htmlBuffer); - const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); - const filePath = resolvePath(url.pathname); - - if (!filePath.startsWith(publicDir)) { - res.writeHead(403); - res.end("Forbidden"); - return; - } - - try { - await sendResponse(res, filePath); - } catch (error: any) { - if (error.code === "ENOENT") { - const notFoundPath = path.join(publicDir, "404.html"); - try { - await sendResponse(res, notFoundPath, 404); - } catch { - res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("404 - Recurso no encontrado"); - } - } else if (error.code === "EISDIR") { - const indexPath = path.join(filePath, "index.html"); - await sendResponse(res, indexPath); - } else { - console.error("[Server] Error al servir archivo:", error); - res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("500 - Error interno del servidor"); - } - } - } catch (error) { - console.error("[Server] Error inesperado:", error); - res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("500 - Error interno"); + // Conditional ETag for dynamic page (fresh each deploy change) + const inm = (req.headers["if-none-match"] as string) || ""; + if (inm && inm === etag) { + res.writeHead( + 304, + applySecurityHeaders({ ETag: etag, "Cache-Control": "no-cache" }) + ); + res.end(); + return; } -}); + + let respBody: any = htmlBuffer; + const headers: Record = { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-cache", + ETag: etag, + }; + + if (acceptsEncoding(req, "gzip")) { + try { + respBody = gzipSync(htmlBuffer); + headers["Content-Encoding"] = "gzip"; + headers["Vary"] = "Accept-Encoding"; + } catch { + // continuar sin comprimir + } + } + + res.writeHead(statusCode, applySecurityHeaders(headers)); + res.end(respBody); +}; + +export const server = createServer( + async (req: IncomingMessage, res: ServerResponse) => { + try { + // 🔒 Forzar HTTPS en producción (Heroku) + if (process.env.NODE_ENV === "production") { + const proto = req.headers["x-forwarded-proto"]; + if (proto && proto !== "https") { + res.writeHead(301, { + Location: `https://${req.headers.host}${req.url}`, + }); + return res.end(); + } + } + + const url = new URL( + req.url ?? "/", + `http://${req.headers.host ?? "localhost"}` + ); + + // Basic hardening: respond to robots.txt quickly (optional: disallow all or keep current) + if (url.pathname === "/robots.txt") { + const robots = "User-agent: *\nAllow: /\n"; // change to Disallow: / if you want to discourage polite crawlers + res.writeHead( + 200, + applySecurityHeaders({ + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=86400", + }) + ); + return res.end(robots); + } + + const clientIp = getClientIp(req); + if (isSuspiciousPath(url.pathname)) { + // Hard block known-bad keyword probes + if (BLOCKED_PATTERNS.some((re) => re.test(url.pathname))) { + const headers = applySecurityHeaders({ + "Content-Type": "text/plain; charset=utf-8", + }); + res.writeHead(403, headers); + return res.end("Forbidden"); + } + // Rate limit repetitive suspicious hits per IP + const rate = hitSuspicious(clientIp); + if (!rate.allowed) { + const headers = applySecurityHeaders({ + "Content-Type": "text/plain; charset=utf-8", + "Retry-After": String(Math.ceil(rate.resetIn / 1000)), + "X-RateLimit-Limit": String(RATE_MAX_SUSPICIOUS), + "X-RateLimit-Remaining": String(rate.remaining), + }); + res.writeHead(429, headers); + return res.end("Too Many Requests"); + } + } + + // Ruta dinámica: renderizar index con EJS + if ( + url.pathname === "/" || + url.pathname === "/index" || + url.pathname === "/index.html" + ) { + const now = new Date(); + const currentDateHuman = now.toLocaleDateString("es-ES", { + month: "long", + year: "numeric", + }); + const djsVersion = pkg?.dependencies?.["discord.js"] ?? "15.0.0-dev"; + await renderTemplate(req, res, "index", { + appName: pkg.name ?? "Amayo Bot", + version: pkg.version ?? "2.0.0", + djsVersion, + currentDateHuman, + }); + return; + } + + const filePath = resolvePath(url.pathname); + + if (!filePath.startsWith(publicDir)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + try { + await sendResponse(req, res, filePath); + } catch (error: any) { + if (error.code === "ENOENT") { + const notFoundPath = path.join(publicDir, "404.html"); + try { + await sendResponse(req, res, notFoundPath, 404); + } catch { + res.writeHead( + 404, + applySecurityHeaders({ + "Content-Type": "text/plain; charset=utf-8", + }) + ); + res.end("404 - Recurso no encontrado"); + } + } else if (error.code === "EISDIR") { + const indexPath = path.join(filePath, "index.html"); + await sendResponse(req, res, indexPath); + } else { + console.error("[Server] Error al servir archivo:", error); + res.writeHead( + 500, + applySecurityHeaders({ + "Content-Type": "text/plain; charset=utf-8", + }) + ); + res.end("500 - Error interno del servidor"); + } + } + } catch (error) { + console.error("[Server] Error inesperado:", error); + res.writeHead( + 500, + applySecurityHeaders({ "Content-Type": "text/plain; charset=utf-8" }) + ); + res.end("500 - Error interno"); + } + } +); diff --git a/src/server/views/index.ejs b/src/server/views/index.ejs new file mode 100644 index 0000000..847fb7a --- /dev/null +++ b/src/server/views/index.ejs @@ -0,0 +1,108 @@ +<% const pageTitle = `${appName} | Guía Completa`; %> +<% const head = null; %> +<% const scripts = null; %> +<% const title = pageTitle; %> +<% layout('layouts/layout', { title, head, scripts, appName, version, djsVersion, currentDateHuman }); %> + +
+
+ +
+
+
+ + + + + + <%= appName %> • v<%= version %> + +
+ +

+ + Guía Completa + + + <%= appName %> + +

+ +

+ Sistema completo de economía, minijuegos, misiones y IA para Discord +

+ +
+ +
+
+ + Comenzar ahora + + + + +
+ + +
+ +
+ + Discord.js <%= djsVersion %> + + + <%= currentDateHuman %> + + + 23 Secciones • Creación de Contenido Incluida + +
+
+
+
+ +
+
+ +
+ <%- include('partials/toc') %> + +
+
+ <%- include('partials/sections/primeros-pasos') %> + <%- include('partials/sections/comandos-basicos') %> + <%- include('partials/sections/sistema-juego') %> + <%- include('partials/sections/minijuegos') %> + <%- include('partials/sections/inventario-equipo') %> + <%- include('partials/sections/economia') %> + <%- include('partials/sections/tienda') %> + <%- include('partials/sections/crafteo') %> + <%- include('partials/sections/logros') %> + <%- include('partials/sections/misiones') %> + <%- include('partials/sections/racha') %> + <%- include('partials/sections/consumibles') %> + <%- include('partials/sections/cofres') %> + <%- include('partials/sections/encantamientos') %> + <%- include('partials/sections/fundicion') %> + <%- include('partials/sections/ia') %> + <%- include('partials/sections/recordatorios') %> + <%- include('partials/sections/alianzas') %> + <%- include('partials/sections/admin') %> + <%- include('partials/sections/creacion-contenido') %> + <%- include('partials/sections/configuracion') %> + <%- include('partials/sections/estadisticas') %> + <%- include('partials/sections/tips') %> + <%- include('partials/sections/faq') %> +
+
+
+
+
diff --git a/src/server/views/layouts/layout.ejs b/src/server/views/layouts/layout.ejs new file mode 100644 index 0000000..86421b1 --- /dev/null +++ b/src/server/views/layouts/layout.ejs @@ -0,0 +1,106 @@ + + + + + + <%= title || `${appName} | Guía Completa` %> + + + + + + + + + <% if (typeof head !== 'undefined' && head) { %> + <%= head %> + <% } %> + + + + <%- await include('../partials/navbar', { appName }) %> + + +
+
+
+
+
+ +
+ <%- body %> +
+ +
+
+
+

<%= appName %>

+

Sistema completo de juego, economía y gestión para Discord

+
+ +
+ Primeros Pasos + + Comandos + + Minijuegos + + FAQ +
+ +
+

+ Versión <%= version %> • Discord.js <%= djsVersion %> • <%= currentDateHuman %> +

+

+ Amayo © <%= new Date().getFullYear() %> — Documentación para usuarios finales de Discord +

+
+ + +
+
+ + + + <% if (typeof scripts !== 'undefined' && scripts) { %> + <%= scripts %> + <% } %> + + diff --git a/src/server/views/pages/index.ejs b/src/server/views/pages/index.ejs new file mode 100644 index 0000000..3568d8b --- /dev/null +++ b/src/server/views/pages/index.ejs @@ -0,0 +1,99 @@ +
+
+ +
+
+
+ + + + + + <%= appName %> • v<%= version %> + +
+ +

+ + Guía Completa + + + <%= appName %> + +

+ +

+ Sistema completo de economía, minijuegos, misiones y IA para Discord +

+ + + +
+ + Discord.js <%= djsVersion %> + + + <%= currentDateHuman %> + + + 23 Secciones • Creación de Contenido Incluida + +
+
+
+
+ +
+
+ +
+ <%- await include('../partials/toc') %> + +
+
+ <%- await include('../partials/sections/primeros-pasos') %> + <%- await include('../partials/sections/comandos-basicos') %> + <%- await include('../partials/sections/ejemplos-basicos') %> + <%- await include('../partials/sections/ejemplos-avanzados') %> + <%- await include('../partials/sections/sistema-juego') %> + <%- await include('../partials/sections/minijuegos') %> + <%- await include('../partials/sections/inventario-equipo') %> + <%- await include('../partials/sections/economia') %> + <%- await include('../partials/sections/tienda') %> + <%- await include('../partials/sections/crafteo') %> + <%- await include('../partials/sections/logros') %> + <%- await include('../partials/sections/misiones') %> + <%- await include('../partials/sections/racha') %> + <%- await include('../partials/sections/consumibles') %> + <%- await include('../partials/sections/cofres') %> + <%- await include('../partials/sections/encantamientos') %> + <%- await include('../partials/sections/fundicion') %> + <%- await include('../partials/sections/ia') %> + <%- await include('../partials/sections/recordatorios') %> + <%- await include('../partials/sections/alianzas') %> + <%- await include('../partials/sections/admin') %> + <%- await include('../partials/sections/creacion-contenido') %> + <%- await include('../partials/sections/configuracion') %> + <%- await include('../partials/sections/estadisticas') %> + <%- await include('../partials/sections/tips') %> + <%- await include('../partials/sections/faq') %> +
+
+ + +
+
+
diff --git a/src/server/views/partials/navbar.ejs b/src/server/views/partials/navbar.ejs new file mode 100644 index 0000000..dae3f80 --- /dev/null +++ b/src/server/views/partials/navbar.ejs @@ -0,0 +1,16 @@ + diff --git a/src/server/views/partials/rightSidebar.ejs b/src/server/views/partials/rightSidebar.ejs new file mode 100644 index 0000000..791c964 --- /dev/null +++ b/src/server/views/partials/rightSidebar.ejs @@ -0,0 +1,32 @@ +
+ +
+
+

Apoya el proyecto

+
+
+ +
+
+ + +
+

Novedades

+
    +
  • • Nueva guía con layout EJS
  • +
  • • Mejoras de rendimiento del bot
  • +
  • • Sistema de economía ampliado
  • +
+
+ +
+

Recursos útiles

+ +
+
diff --git a/src/server/views/partials/sections/admin.ejs b/src/server/views/partials/sections/admin.ejs new file mode 100644 index 0000000..37e89a7 --- /dev/null +++ b/src/server/views/partials/sections/admin.ejs @@ -0,0 +1,4 @@ +
+

⚙️ Panel de Administración

+

Contenido en migración a EJS…

+
diff --git a/src/server/views/partials/sections/alianzas.ejs b/src/server/views/partials/sections/alianzas.ejs new file mode 100644 index 0000000..b1e244d --- /dev/null +++ b/src/server/views/partials/sections/alianzas.ejs @@ -0,0 +1,4 @@ +
+

🤝 Sistema de Alianzas

+

Contenido en migración a EJS…

+
diff --git a/src/server/views/partials/sections/cofres.ejs b/src/server/views/partials/sections/cofres.ejs new file mode 100644 index 0000000..559cb5e --- /dev/null +++ b/src/server/views/partials/sections/cofres.ejs @@ -0,0 +1,29 @@ +
+

🎁 Cofres y Recompensas

+

Abre cofres para conseguir recompensas aleatorias: monedas, items o incluso roles.

+ +
+
+

🗝️ Abrir Cofres

+
+ !abrir <itemKey> +
+

Ejemplo: !abrir daily_chest

+
+
+

⚙️ Definición de recompensas

+
+
{
+  "chest": {
+    "enabled": true,
+    "rewards": [
+      { "type": "coins", "amount": 500 },
+      { "type": "item", "itemKey": "health_potion", "qty": 3 }
+    ],
+    "consumeOnOpen": true
+  }
+}
+
+
+
+
diff --git a/src/server/views/partials/sections/comandos-basicos.ejs b/src/server/views/partials/sections/comandos-basicos.ejs new file mode 100644 index 0000000..ef949c2 --- /dev/null +++ b/src/server/views/partials/sections/comandos-basicos.ejs @@ -0,0 +1,26 @@ +
+

⚡ Comandos Básicos

+

+ Estos son los comandos esenciales que necesitas conocer para empezar. +

+ +
+
+

📋 Información y Utilidad

+
+
+ !ayuda [comando|categoría] +

Muestra la lista de comandos. También puedes usar !help, !comandos o !cmds

+
+
+ !ping +

Verifica la latencia del bot. También: !latency, !pong

+
+
+ !player [@usuario] +

Muestra tu perfil completo de jugador con estadísticas, equipo e inventario. También: !perfil, !profile, !yo, !me

+
+
+
+
+
diff --git a/src/server/views/partials/sections/configuracion.ejs b/src/server/views/partials/sections/configuracion.ejs new file mode 100644 index 0000000..f42f4eb --- /dev/null +++ b/src/server/views/partials/sections/configuracion.ejs @@ -0,0 +1,4 @@ +
+

🔧 Configuración del Servidor

+

Contenido en migración a EJS…

+
diff --git a/src/server/views/partials/sections/consumibles.ejs b/src/server/views/partials/sections/consumibles.ejs new file mode 100644 index 0000000..b4dfbeb --- /dev/null +++ b/src/server/views/partials/sections/consumibles.ejs @@ -0,0 +1,28 @@ +
+

🍖 Consumibles y Pociones

+

Usa comida y pociones para curarte o ganar ventajas temporales.

+ +
+
+

🍽️ Uso

+
+ !comer <itemKey> +
+

Ejemplo: !comer minor_healing_potion

+
+
+

⚙️ Props JSON útiles

+
+
{
+  "food": { "healHp": 50, "healPercent": 0, "cooldownSeconds": 60 },
+  "stackable": true, "maxInventory": 10
+}
+
+
+
+ +
+ Cooldowns +

Algunos consumibles comparten cooldown por categoría. Usa !cooldowns para revisarlos.

+
+
diff --git a/src/server/views/partials/sections/crafteo.ejs b/src/server/views/partials/sections/crafteo.ejs new file mode 100644 index 0000000..af93de7 --- /dev/null +++ b/src/server/views/partials/sections/crafteo.ejs @@ -0,0 +1,77 @@ +
+

🛠️ Crafteo

+

Combina materiales para crear objetos. Algunas recetas requieren nivel mínimo o herramientas específicas.

+ +
+
+

📜 Ver Recetas

+
+ !recetas +
+

Lista de recetas disponibles y sus requisitos.

+
+
+

🧪 Crear un Objeto

+
+ !craftear <receta> [cantidad] +
+

Ejemplo: !craftear espada 1

+
+
+ +
+
+

🧭 Cómo craftear (paso a paso)

+
    +
  1. Escribe !recetas y elige el nombre clave de la receta (por ejemplo: espada).
  2. +
  3. Revisa los ingredientes requeridos y tu inventario con !inventario.
  4. +
  5. Si la receta exige nivel/herramienta, verifica tu estado: !perfil y tu equipo con !equipo.
  6. +
  7. Ejecuta !craftear <receta> [cantidad]. Ejemplos: +
    +
    !craftear espada
    +
    !craftear espada 3
    +
    +
  8. +
  9. Si cumples los requisitos, recibirás el/los objeto(s) al instante en tu inventario.
  10. +
+

Consejo: si una receta admite múltiples resultados o variantes, en !recetas verás notas adicionales.

+
+ +
+

📌 Requisitos típicos

+
    +
  • Ingredientes exactos en cantidad suficiente.
  • +
  • Nivel mínimo del jugador para la receta.
  • +
  • Herramienta adecuada equipada (si aplica).
  • +
+
+ Algunas recetas avanzadas podrían requerir materiales raros o pasos previos (p. ej., procesar un material en fundición antes de craftear). +
+
+
+ +
+

✅ Mensajes esperados

+
    +
  • Éxito: “Has crafteado <objeto> x<cantidad>”.
  • +
  • Faltan materiales: “No tienes suficientes <material>”.
  • +
  • Requisito: “Nivel insuficiente para esta receta” o “Necesitas <herramienta> equipada”.
  • +
+
+ +
+ Nota +

Las recetas pueden actualizarse con nuevos parches. Revisa !recetas después de una actualización.

+
+ +
+ Errores comunes y cómo resolverlos +
    +
  • Receta no encontrada: Asegúrate de usar el nombre correcto tal como aparece en !recetas.
  • +
  • Faltan materiales: Junta los recursos con minijuegos o compra/intercambia y vuelve a intentar.
  • +
  • Nivel insuficiente: Sube de nivel con actividades del juego hasta cumplir el requisito.
  • +
  • Herramienta incorrecta o sin durabilidad: Equipa la herramienta adecuada o repárala/sustitúyela.
  • +
  • Cantidad demasiado alta: Reduce la cantidad o craftea en varios intentos.
  • +
+
+
diff --git a/src/server/views/partials/sections/creacion-contenido.ejs b/src/server/views/partials/sections/creacion-contenido.ejs new file mode 100644 index 0000000..4ecec3e --- /dev/null +++ b/src/server/views/partials/sections/creacion-contenido.ejs @@ -0,0 +1,126 @@ +
+

🎨 Creación de Contenido

+

Guía técnica paso a paso para crear items, áreas/niveles, mobs y ofertas directamente desde Discord. Requiere permiso Manage Guild o rol de staff.

+ +
+
+

📦 Items: crear/editar

+
+
1) Crear base
+!item-crear iron_sword
+Base → Nombre: "Espada de Hierro", Descripción, Stackable: false,1
+Tags → weapon, tier2
+
+2) Props (JSON) comunes
+{
+  "tool": { "type": "sword", "tier": 2 },
+  "damage": 15,
+  "breakable": { "enabled": true, "maxDurability": 200 }
+}
+
+3) Receta (modal ⭐)
+Habilitar: true
+Produce: 1
+Ingredientes: iron_ingot:3, wood_plank:1
+
+4) Guardar → ✅ Item creado
+Prueba: !craftear iron_sword
+
+

Usa !item-editar, !item-ver, !items-lista para gestionar.

+
+
+

🧭 Áreas y Niveles (MINE/LAGOON/FIGHT/FARM)

+
+
1) Crear área
+!area-crear mine.iron_cavern
+Config (JSON): {
+  "cooldownSeconds": 60,
+  "description": "Caverna rica en hierro",
+  "icon": "⛏️"
+}
+Guardar → ✅ Área creada
+
+2) Crear nivel 1
+!area-nivel mine.iron_cavern 1
+Requisitos (JSON): {
+  "tool": { "required": true, "toolType": "pickaxe", "minTier": 2 }
+}
+Recompensas (JSON): {
+  "draws": 2,
+  "table": [
+    { "type": "coins", "amount": 50, "weight": 10 },
+    { "type": "item",  "itemKey": "iron_ore", "qty": 2, "weight": 8 }
+  ]
+}
+Mobs (JSON, opcional): {
+  "draws": 1,
+  "table": [ { "mobKey": "cave_spider", "weight": 10 } ]
+}
+Guardar → ✅ Nivel guardado
+
+
+
Errores comunes
+
    +
  • mobKey o itemKey inexistente → crea primero o corrige la key
  • +
  • Pesos mal balanceados → revisa weight (no negativos; no tienen que sumar 100)
  • +
  • Herramienta requerida mal configurada → revisa toolType y minTier
  • +
+
+
+
+ +
+
+

👹 Mobs (enemigos/NPCs)

+
+
1) Crear mob
+!mob-crear goblin
+Base → Nombre: Goblin, Categoría: enemies
+Stats (JSON): { "attack": 10, "hp": 50, "defense": 3, "xpReward": 25 }
+Drops (JSON): {
+  "draws": 2,
+  "table": [
+    { "type": "coins", "amount": 20, "weight": 10 },
+    { "type": "item",  "itemKey": "leather", "qty": 1, "weight": 5 }
+  ]
+}
+Guardar → ✅ Mob creado
+
+

Revisa con !mobs-lista y !mob-eliminar <key> si necesitas limpiar datos de prueba.

+
+
+

🛒 Ofertas de Tienda

+
+
1) Crear oferta
+!offer-crear
+Base → itemKey: iron_sword, Habilitada: true
+Precio (JSON): { "coins": 100 }
+— o —
+Precio (JSON): {
+  "coins": 50,
+  "items": [ { "itemKey": "iron_ore", "qty": 5 } ]
+}
+Límites → por usuario: 5, stock global: 100
+Ventana → inicio/fin ISO (opcional)
+Guardar → ✅ Oferta guardada
+
+
+
Errores comunes
+
    +
  • itemKey no existe → crea el ítem primero
  • +
  • Formato de precio inválido → respeta estructura de coins e items
  • +
  • Ventana inválida → usa fechas ISO: YYYY-MM-DDTHH:MM:SSZ
  • +
+
+
+
+ +
+ Recomendaciones +
    +
  • Usa keys en minúsculas y sin espacios (únicas por servidor).
  • +
  • Guarda plantillas de Props JSON para acelerar creación.
  • +
  • Prueba inmediatamente tras crear: !craftear, !abrir, !equipar.
  • +
+
+
diff --git a/src/server/views/partials/sections/economia.ejs b/src/server/views/partials/sections/economia.ejs new file mode 100644 index 0000000..d3005ec --- /dev/null +++ b/src/server/views/partials/sections/economia.ejs @@ -0,0 +1,31 @@ +
+

💰 Sistema de Economía

+

Gana y gestiona monedas para comprar items, participar en eventos y mejorar tu progreso.

+ +
+
+

🪙 Ver tus Monedas

+
+
+ !monedas [@usuario] +
+

Muestra el balance de monedas tuyo o de otro usuario.

+
+
+
+

💸 Cómo Ganar Monedas

+
    +
  • Jugar minijuegos (minar, pescar, pelear, plantar)
  • +
  • Completar misiones
  • +
  • Mantener tu racha diaria
  • +
  • Abrir cofres
  • +
  • Vender items (si está habilitado)
  • +
+
+
+ +
+ ⚠️ Importante: +

Las monedas son específicas por servidor. Cada servidor de Discord tiene su propia economía independiente.

+
+
diff --git a/src/server/views/partials/sections/ejemplos-avanzados.ejs b/src/server/views/partials/sections/ejemplos-avanzados.ejs new file mode 100644 index 0000000..43ff744 --- /dev/null +++ b/src/server/views/partials/sections/ejemplos-avanzados.ejs @@ -0,0 +1,55 @@ +
+

🧪 Ejemplos Avanzados

+

Workflows completos inspirados en tu documentación para staff. Sigue los pasos y copia/pega los JSON cuando se soliciten.

+ +
+
+

1) Sistema de Minería (básico)

+
# Ítem Herramienta
+!item-crear wooden_pickaxe
+Props (JSON): {"tool": {"type": "pickaxe", "tier": 1}, "breakable": {"enabled": true, "maxDurability": 50, "durabilityPerUse": 1}}
+
+# Ítem Recurso
+!item-crear copper_ore
+Props (JSON): {"craftingOnly": false}
+
+# Área y Nivel
+!area-crear mine.starter
+Config (JSON): {"cooldownSeconds": 30, "icon": "⛏️"}
+!area-nivel mine.starter 1
+Requisitos (JSON): {"tool": {"required": true, "toolType": "pickaxe", "minTier": 1}}
+Recompensas (JSON): {"draws": 2, "table": [{"type":"coins","amount":10,"weight":10},{"type":"item","itemKey":"copper_ore","qty":1,"weight":8}]}
+
+ +
+

2) Cofre de Recompensa Diaria

+
!item-crear daily_chest
+Props (JSON): {
+  "chest": {"enabled": true, "rewards": [
+    {"type": "coins", "amount": 500},
+    {"type": "item", "itemKey": "health_potion", "qty": 3}
+  ], "consumeOnOpen": true}
+}
+
+ +
+

3) Espada Legendaria (cadena resumida)

+
# Materiales base → fundición → crafteo
+!item-crear magic_dust
+!item-crear steel_ingot
+# (fundición configurada por el equipo)
+
+# Producto intermedio
+!item-crear steel_sword_base
+Props (JSON): {"tool": {"type": "sword", "tier": 3}, "damage": 25}
+
+# Encantamiento aplicado
+!encantar steel_sword_base ruby_core
+
+# Producto final
+!item-crear legendary_dragon_slayer
+Props (JSON): {"damage": 45, "breakable": {"enabled": true, "maxDurability": 300}}
+Receta (modal): steel_sword_base:1, magic_dust:3, dragon_scale:1
+
+
+
diff --git a/src/server/views/partials/sections/ejemplos-basicos.ejs b/src/server/views/partials/sections/ejemplos-basicos.ejs new file mode 100644 index 0000000..3aff4d4 --- /dev/null +++ b/src/server/views/partials/sections/ejemplos-basicos.ejs @@ -0,0 +1,47 @@ +
+

🧭 Ejemplos Básicos

+

Un arranque rápido con los comandos más usados. Copia y pega en tu servidor:

+ +
+
+

👤 Perfil y progreso

+
+
!player
+!stats
+!logros
+!misiones
+!cooldowns
+
+
+ +
+

🎮 Minijuegos

+
+
!mina
+!pescar
+!pelear
+!plantar
+
+

Tip: Puedes pasar nivel o herramienta, ej. !mina 2 iron_pickaxe

+
+ +
+

🎒 Inventario y equipo

+
+
!inventario
+!equipar weapon iron_sword
+!comer minor_healing_potion
+
+
+ +
+

💰 Economía

+
+
!monedas
+!tienda
+!comprar health_potion 2
+!craftear iron_sword
+
+
+
+
diff --git a/src/server/views/partials/sections/encantamientos.ejs b/src/server/views/partials/sections/encantamientos.ejs new file mode 100644 index 0000000..d2b5f29 --- /dev/null +++ b/src/server/views/partials/sections/encantamientos.ejs @@ -0,0 +1,26 @@ +
+

✨ Encantamientos

+

Aplica mutaciones para mejorar armas, armaduras o herramientas según políticas por ítem.

+ +
+
+

🪄 Usar encantamientos

+
+ !encantar <itemKey> <mutationKey> +
+

Ejemplo: !encantar iron_sword ruby_core

+
+
+

⚙️ Política por item (Props)

+
+
{
+  "mutationPolicy": {
+    "allowedKeys": ["ruby_core", "emerald_core", "sharpness_enchant"],
+    "deniedKeys": ["curse_weakness"]
+  }
+}
+
+

Define llaves permitidas/prohibidas por ítem.

+
+
+
diff --git a/src/server/views/partials/sections/estadisticas.ejs b/src/server/views/partials/sections/estadisticas.ejs new file mode 100644 index 0000000..a742da4 --- /dev/null +++ b/src/server/views/partials/sections/estadisticas.ejs @@ -0,0 +1,4 @@ +
+

📊 Estadísticas y Progreso

+

Contenido en migración a EJS…

+
diff --git a/src/server/views/partials/sections/faq.ejs b/src/server/views/partials/sections/faq.ejs new file mode 100644 index 0000000..f4dc5ef --- /dev/null +++ b/src/server/views/partials/sections/faq.ejs @@ -0,0 +1,67 @@ +
+

❓ Preguntas Frecuentes

+
+
+ ¿Puedo editar un item después de crearlo? +
+ Sí, usa !item-editar <key>. Para ver detalles sin editar: !item-ver <key>. +
+
+ +
+ ¿Cómo elimino un item? +
+ Usa !item-eliminar <key>. Atención: es permanente y no se puede deshacer. +
+
+ +
+ ¿Cómo veo todos los items creados? +
+ !items-lista [página] muestra una lista paginada con botones para ver detalles. +
+
+ +
+ ¿Qué formato tienen las fechas ISO? +
+ Usa YYYY-MM-DDTHH:MM:SSZ. Ejemplos: 2025-01-15T00:00:00Z, 2025-12-25T23:59:59Z. +
+
+ +
+ ¿Puedo crear items globales? +
+ Solo los administradores del bot pueden crear items globales. Los items que crees serán locales a tu servidor. +
+
+ +
+ ¿Cuántos niveles puedo crear por área? +
+ No hay límite técnico; se recomiendan 5–10 por área para una progresión balanceada. +
+
+ +
+ ¿Qué pasa si un jugador no tiene la herramienta requerida? +
+ El bot indica la herramienta y el tier mínimo necesarios según los requisitos del nivel. +
+
+ +
+ ¿Cómo funcionan los pesos (weights)? +
+ Son probabilidades relativas. Si A tiene peso 10 y B peso 5, A es el doble de probable (10/15 vs 5/15). +
+
+ +
+ ¿Puedo hacer que un ítem cure porcentaje de vida? +
+ Sí, en props usa food.healPercent (ej. 50) y un cooldown con food.cooldownSeconds. +
+
+
+
diff --git a/src/server/views/partials/sections/fundicion.ejs b/src/server/views/partials/sections/fundicion.ejs new file mode 100644 index 0000000..7d612c9 --- /dev/null +++ b/src/server/views/partials/sections/fundicion.ejs @@ -0,0 +1,24 @@ +
+

🔥 Sistema de Fundición

+

Transforma materiales crudos en recursos refinados con tiempo de espera y reclamo.

+ +
+
+

⏳ Flujo de uso

+
    +
  1. Inicia: !fundir (ingresa inputs y output)
  2. +
  3. Espera el tiempo indicado
  4. +
  5. Reclama: !fundir-reclamar
  6. +
+
+
+

🧪 Receta ejemplo

+
+
Input: copper_ore x5, coal x2
+Output: copper_ingot x2
+Duración: 300s
+
+

La configuración exacta la define el equipo en base de datos.

+
+
+
diff --git a/src/server/views/partials/sections/ia.ejs b/src/server/views/partials/sections/ia.ejs new file mode 100644 index 0000000..ae43298 --- /dev/null +++ b/src/server/views/partials/sections/ia.ejs @@ -0,0 +1,4 @@ +
+

🤖 Inteligencia Artificial

+

Contenido en migración a EJS…

+
diff --git a/src/server/views/partials/sections/inventario-equipo.ejs b/src/server/views/partials/sections/inventario-equipo.ejs new file mode 100644 index 0000000..fcc6894 --- /dev/null +++ b/src/server/views/partials/sections/inventario-equipo.ejs @@ -0,0 +1,48 @@ +
+

🎒 Inventario y Equipo

+

+ Gestiona todos tus items y equipa armas, armaduras y capas para mejorar tus estadísticas. +

+ +
+
+

📦 Ver tu Inventario

+
+
+ !inventario [página|filtro] +

Aliases: !inv

+
+

Muestra todos tus items con cantidades, información de herramientas y estadísticas.

+
+
+ +
+

🧤 Equipar Items

+
+
+ !equipar <slot> <itemKey> +

Aliases: !equip

+
+
+
+

⚔️ weapon

+

Armas que aumentan tu daño (ATK)

+
+
+

🛡️ armor

+

Armaduras que aumentan tu defensa (DEF)

+
+
+

🧥 cape

+

Capas con bonos especiales (HP, stats)

+
+
+
+
+
+ +
+ 💡 Tip: +

Usa !player para ver rápidamente tu equipo actual y las estadísticas que te otorgan.

+
+
diff --git a/src/server/views/partials/sections/logros.ejs b/src/server/views/partials/sections/logros.ejs new file mode 100644 index 0000000..a643304 --- /dev/null +++ b/src/server/views/partials/sections/logros.ejs @@ -0,0 +1,28 @@ +
+

🏆 Logros

+

Completa objetivos para obtener recompensas únicas y mostrar tu progreso.

+ +
+
+

📊 Ver tus Logros

+
+ !logros +
+

Muestra logros completados y pendientes, con su progreso.

+
+
+

🎯 Progreso

+
    +
  • Juega minijuegos y combates
  • +
  • Completa misiones
  • +
  • Alcanza rachas diarias
  • +
  • Explora nuevas funciones (crafteo, fundición, etc.)
  • +
+
+
+ +
+ Tip +

Algunos logros otorgan títulos o insignias visibles en el servidor. ¡Presúmelos!

+
+
diff --git a/src/server/views/partials/sections/minijuegos.ejs b/src/server/views/partials/sections/minijuegos.ejs new file mode 100644 index 0000000..62df432 --- /dev/null +++ b/src/server/views/partials/sections/minijuegos.ejs @@ -0,0 +1,57 @@ +
+

🎯 Minijuegos y Actividades

+

+ Los minijuegos son la forma principal de ganar recursos, monedas y experiencia. Cada uno tiene su propio estilo y recompensas. +

+ +
+
+

⛏️ Minar (Mining)

+
+

Ve a la mina y extrae recursos minerales valiosos. Necesitas un pico para minar.

+
+ !mina [nivel] [herramienta] [area:clave] +

Aliases: !minar

+
+
+
+ +
+

🎣 Pescar (Fishing)

+
+

Lanza tu caña en la laguna y captura peces y tesoros acuáticos. Necesitas una caña de pescar.

+
+ !pescar [nivel] [herramienta] [area:clave] +

Aliases: !fish

+
+
+
+ +
+

⚔️ Pelear (Combat)

+
+

Entra a la arena y enfrenta enemigos peligrosos. Las armas mejoran tu daño.

+
+ !pelear [nivel] [arma] [area:clave] +

Aliases: !fight, !arena

+
+
+
+ +
+

🌾 Plantar/Cultivar (Farming)

+
+

Cultiva plantas y cosecha alimentos en tu granja. Usa una azada para mejores resultados.

+
+ !plantar [nivel] [herramienta] +

Aliases: !farm

+
+
+
+
+ +
+ ⏰ Cooldowns: +

Cada minijuego tiene un tiempo de espera (cooldown) entre usos. Usa !cooldowns para ver tus tiempos activos.

+
+
diff --git a/src/server/views/partials/sections/misiones.ejs b/src/server/views/partials/sections/misiones.ejs new file mode 100644 index 0000000..2ae83f6 --- /dev/null +++ b/src/server/views/partials/sections/misiones.ejs @@ -0,0 +1,34 @@ +
+

📝 Misiones

+

Tareas con objetivos y recompensas. Úsalas para guiar la progresión diaria y semanal.

+ +
+
+

📋 Ver y Reclamar

+
    +
  • !misiones — Ver misiones disponibles
  • +
  • !mision-reclamar <key> — Reclamar recompensa
  • +
+
+
+

🛠️ Crear como Admin

+
+
+
!mision-crear daily_mining_quest
+Base: Nombre, Descripción, Tipo (daily/weekly/one_time)
+Requisitos (JSON): { "type": "mine_count", "count": 10 }
+Recompensas (JSON): { "coins": 1000, "xp": 500 }
+
+

Edita desde Discord con botones y modales; no necesitas código.

+
+
+
+ +
+ Tipos y requisitos comunes +
    +
  • Tipos: daily, weekly, one_time, repeatable
  • +
  • Requisitos: mine_count, fish_count, fight_count, collect_items, defeat_mobs
  • +
+
+
diff --git a/src/server/views/partials/sections/primeros-pasos.ejs b/src/server/views/partials/sections/primeros-pasos.ejs new file mode 100644 index 0000000..0be2c97 --- /dev/null +++ b/src/server/views/partials/sections/primeros-pasos.ejs @@ -0,0 +1,39 @@ +
+

🚀 Primeros Pasos

+

+ ¡Bienvenido a Amayo Bot! Este bot transforma tu servidor de Discord en una experiencia de juego completa con economía, minijuegos, misiones y mucho más. +

+ +
+

✨ ¿Qué puedes hacer con Amayo?

+
    +
  • Jugar Minijuegos: Mina recursos, pesca, pelea contra enemigos y cultiva en granjas
  • +
  • Economía Completa: Gana monedas, compra en la tienda, craftea items y gestiona tu inventario
  • +
  • Sistema de Progresión: Sube de nivel, completa misiones, desbloquea logros y mantén tu racha diaria
  • +
  • Personalización: Equipa armas, armaduras y capas para mejorar tus estadísticas
  • +
  • IA Conversacional: Chatea con Gemini AI directamente desde Discord
  • +
  • Sistema de Alianzas: Comparte enlaces de invitación y gana puntos para tu servidor
  • +
+
+ +
+
+

⚡ Prefix del Bot

+

+ El prefix por defecto es ! +

+

+ Los administradores pueden cambiarlo con !configuracion +

+
+
+

❓ Obtener Ayuda

+

+ Usa !ayuda para ver todos los comandos disponibles +

+

+ También puedes usar !ayuda <comando> para detalles específicos +

+
+
+
diff --git a/src/server/views/partials/sections/racha.ejs b/src/server/views/partials/sections/racha.ejs new file mode 100644 index 0000000..59e68ad --- /dev/null +++ b/src/server/views/partials/sections/racha.ejs @@ -0,0 +1,22 @@ +
+

🔥 Racha Diaria

+

Entra cada día y realiza acciones para mantener tu racha. A mayor racha, mejores recompensas.

+ +
+
+

📆 Comandos útiles

+
    +
  • !racha — Ver tu racha actual
  • +
  • !cooldowns — Revisa tiempos de espera de minijuegos
  • +
+
+
+

🎁 Beneficios

+
    +
  • Bonos de monedas diarios o semanales
  • +
  • Acceso a cofres o misiones especiales
  • +
  • Progreso extra en logros
  • +
+
+
+
diff --git a/src/server/views/partials/sections/recordatorios.ejs b/src/server/views/partials/sections/recordatorios.ejs new file mode 100644 index 0000000..a7bfdb7 --- /dev/null +++ b/src/server/views/partials/sections/recordatorios.ejs @@ -0,0 +1,4 @@ +
+

⏰ Sistema de Recordatorios

+

Contenido en migración a EJS…

+
diff --git a/src/server/views/partials/sections/sistema-juego.ejs b/src/server/views/partials/sections/sistema-juego.ejs new file mode 100644 index 0000000..e2f3a09 --- /dev/null +++ b/src/server/views/partials/sections/sistema-juego.ejs @@ -0,0 +1,36 @@ +
+

🎮 Sistema de Juego

+

+ El sistema de juego de Amayo incluye HP (puntos de vida), estadísticas de combate, niveles de progresión y más. +

+ +
+
+

⚔️ Estadísticas de Combate

+
    +
  • HP (Vida): Tus puntos de vida actuales y máximos
  • +
  • ATK (Ataque): Daño que infliges a los enemigos
  • +
  • DEF (Defensa): Reduce el daño recibido
  • +
  • Bonos de Equipo: Las armas, armaduras y capas mejoran tus stats
  • +
+
+
+

📊 Ver tus Estadísticas

+
+
+ !player +

Vista general de tu perfil

+
+
+ !stats +

Estadísticas detalladas de todas tus actividades

+
+
+
+
+ +
+ 💡 Consejo: +

Equipa mejores armas y armaduras para aumentar tus estadísticas y tener más éxito en los minijuegos de combate.

+
+
diff --git a/src/server/views/partials/sections/tienda.ejs b/src/server/views/partials/sections/tienda.ejs new file mode 100644 index 0000000..a12ab5a --- /dev/null +++ b/src/server/views/partials/sections/tienda.ejs @@ -0,0 +1,28 @@ +
+

🛒 Tienda

+

Compra y vende objetos utilizando tus monedas. La disponibilidad puede variar según el servidor.

+ +
+
+

🧾 Ver Catálogo

+
+
+ !tienda +
+

Muestra la lista de items disponibles actualmente.

+
+
+
+

🛍️ Comprar Items

+
+ !comprar <item> [cantidad] +
+

Ejemplo: !comprar pocion 3

+
+
+ +
+ Consejo +

Algunos items tienen descuentos temporales o packs especiales. ¡Revisa la tienda con frecuencia!

+
+
diff --git a/src/server/views/partials/sections/tips.ejs b/src/server/views/partials/sections/tips.ejs new file mode 100644 index 0000000..571f913 --- /dev/null +++ b/src/server/views/partials/sections/tips.ejs @@ -0,0 +1,4 @@ +
+

💡 Tips y Trucos

+

Contenido en migración a EJS…

+
diff --git a/src/server/views/partials/toc.ejs b/src/server/views/partials/toc.ejs new file mode 100644 index 0000000..1483c2f --- /dev/null +++ b/src/server/views/partials/toc.ejs @@ -0,0 +1,32 @@ +