diff --git a/README/CLEANUP.md b/README/CLEANUP.md new file mode 100644 index 0000000..5d845cc --- /dev/null +++ b/README/CLEANUP.md @@ -0,0 +1,56 @@ +# Resumen de la limpieza de mobs inválidos + +Qué hice + +- Detecté mob definitions inválidas en la tabla `Mob` usando `scripts/cleanInvalidMobs.ts`. +- Guardé un respaldo completo de las filas inválidas en `invalid_mobs_backup.json` en la raíz del repo. +- Busqué dependencias en tablas que referencian `Mob` con `scripts/findMobDependencies.ts`. +- Guardé un respaldo de las filas dependientes en `scheduled_mob_attack_backup.json`. +- Eliminé las filas dependientes en `ScheduledMobAttack` y luego eliminé las filas inválidas de `Mob` con `scripts/removeInvalidMobsWithDeps.ts`. + +Backups + +- `invalid_mobs_backup.json` — contiene objetos con `id`, `row` (registro original) y `error` (ZodError). +- `scheduled_mob_attack_backup.json` — contiene filas de `ScheduledMobAttack` que apuntaban a los mobs inválidos. + +Comandos usados + +- Detectar mobs inválidos (no destructivo): + + npx tsx scripts/testMobData.ts + +- Generar backup + eliminar mobs inválidos (no recomendado sin revisar): + + XATA_DB=... npx tsx scripts/cleanInvalidMobs.ts + +- Buscar dependencias (muestra FK y filas dependientes): + + XATA_DB=... npx tsx scripts/findMobDependencies.ts + +- Backups + borrado de dependencias y mobs (ya ejecutado): + + XATA_DB=... npx tsx scripts/removeInvalidMobsWithDeps.ts + +Restauración + +- Si deseas restaurar datos desde los backups, hay dos estrategias: + + 1) Restauración completa (reinsertar mobs, luego scheduled attacks): requiere restaurar `Mob` antes de `ScheduledMobAttack`. + + 2) Restauración parcial (solo scheduled attacks): solo posible si los `Mob` existen o se restauran con anterioridad. + +Pistas para restaurar (ejemplo rápido) + +- Para reinserción manual (SQL generada): examina `invalid_mobs_backup.json`, reconstruye los objetos `metadata` y ejecuta INSERTs en la tabla `Mob` respetando columnas. + +- Para reinserción de `ScheduledMobAttack`: usa `scheduled_mob_attack_backup.json` e inserta filas; asegúrate de que `mobId` apunte a un `Mob` existente. + +Siguientes pasos recomendados + +- Revisar backups antes de cualquier restauración. +- Añadir validación previa a la UI que crea mobs para evitar shapes inválidos (ya existe zod pero su uso puede ampliarse). +- Añadir tests DB-backed en staging para evitar que filas inválidas lleguen a producción. + +Contacto + +- Si quieres, puedo generar un script de restauración `scripts/restoreFromBackup.ts` que hace esto de forma idempotente y segura. Pídelo y lo creo. diff --git a/README/CREATE_MOB.md b/README/CREATE_MOB.md new file mode 100644 index 0000000..66bcac1 --- /dev/null +++ b/README/CREATE_MOB.md @@ -0,0 +1,111 @@ +Crear un Mob (guía para usuario final) + +Este documento explica cómo crear o editar un mob en el proyecto Amayo. +Incluye: campos obligatorios, ejemplos JSON, validación y formas de persistir (UI o DB). + +1) Datos requeridos + +- key (string, único): identificador del mob. Ej: "slime.green". +- name (string): nombre legible. Ej: "Slime Verde". +- tier (number): nivel/tier del mob (entero no negativo). Ej: 1. +- base (objeto): contiene stats base obligatorias: + - hp (number): puntos de vida base. + - attack (number): valor de ataque base. + - defense (number, opcional): defensa base. + +2) Campos opcionales útiles + +- scaling (objeto): parámetros de escalado por nivel de área. + - hpPerLevel, attackPerLevel, defensePerLevel (number, opcional) + - hpMultiplierPerTier (number, opcional) +- tags (string[]): etiquetas libres (ej: ["undead","slime"]). +- rewardMods (objeto): ajustes de recompensa (coinMultiplier, extraDropChance). +- behavior (objeto): comportamiento en combate (maxRounds, aggressive, critChance, critMultiplier). + +3) Ejemplo JSON mínimo + +{ + "key": "slime.green", + "name": "Slime Verde", + "tier": 1, + "base": { "hp": 18, "attack": 4 } +} + +4) Ejemplo JSON completo + +{ + "key": "skeleton.basic", + "name": "Esqueleto", + "tier": 2, + "base": { "hp": 30, "attack": 6, "defense": 1 }, + "scaling": { "hpPerLevel": 4, "attackPerLevel": 0.8, "defensePerLevel": 0.2 }, + "tags": ["undead"], + "rewardMods": { "coinMultiplier": 1.1, "extraDropChance": 0.05 }, + "behavior": { "aggressive": true, "critChance": 0.05, "critMultiplier": 1.5 } +} + + ## Formato de "drops" soportado + + Para permitir que los mobs otorguen ítems al morir o por `extraDropChance`, el motor soporta dos formatos para el campo `drops` en la definición del mob: + + - Formato BÁSICO (mapa simple): + + ```json + {"ore.iron": 1, "ore.gold": 1} + ``` + + Cada key es `itemKey` y el valor es la cantidad (qty). Cuando se necesita elegir un ítem se selecciona una key al azar. + + - Formato PONDERADO (array): + + ```json + [ + { "itemKey": "ore.iron", "qty": 1, "weight": 8 }, + { "itemKey": "ore.gold", "qty": 1, "weight": 2 } + ] + ``` + + Cada entrada puede incluir `weight` (entero/numero) para definir probabilidad relativa. El motor hace una tirada ponderada y entrega el item seleccionado. + + Si no hay `drops` configurados o la selección falla, se aplicará un `fallback` que entrega 1 moneda. + + +5) Validación + +El proyecto usa Zod para validar la definición. Puedes ejecutar localmente: + + npx tsx scripts/testMobData.ts + +Eso intentará inicializar el repositorio de mobs y mostrará errores Zod si la definición es inválida. + +6) Formas de persistir + +- Interfaz del bot (UI): actualmente algunos comandos admin usan `createOrUpdateMob` y persisten en la tabla `Mob` en la DB. Usa los comandos del bot (si tienes permisos administrador) para crear/editar mobs. + +- Directamente en la DB: insertar un row en la tabla `Mob` con `metadata` JSON conteniendo la definición. Recomendado: usa `createOrUpdateMob` o valida con Zod antes. + +7) Pruebas y comprobaciones + +- Obtener una instancia de prueba: + - Usa `src/game/mobs/mobData.getMobInstance(key, areaLevel)` en la consola o en un script para ver stats escaladas. +- Lista de keys disponibles: + - `src/game/mobs/mobData.listMobKeys()` + +8) Precauciones + +- Evita crear mobs con shapes incompletas (faltas de `key`, `name`, `tier`, `base`) — provocará rechazos de validación y puede romper procesos que esperen campos. +- Si actualizas a mano la DB, haz un backup antes y valida con Zod. + +9) Soporte + +- Si quieres que el equipo integre campos adicionales (por ejemplo `lootTable`), coméntalo y añadiré la extensión al esquema y pruebas. + +--- + +Creado automáticamente por scripts de mantenimiento. Si quieres que lo formatee o añada más ejemplos, lo actualizo. + +--- + +Sección añadida: Formatos de drops + +Para detalle técnico sobre cómo definir `drops` en la definición de un mob (soporte de mapa simple y array ponderado), ver la sección "Formato de \"drops\" soportado" al final de este README o en los comentarios del archivo `src/game/mobs/mobData.ts`. diff --git a/invalid_mobs_backup.json b/invalid_mobs_backup.json new file mode 100644 index 0000000..9fd9ded --- /dev/null +++ b/invalid_mobs_backup.json @@ -0,0 +1,131 @@ +[ + { + "id": "cmgqyuj2h000zmofwe8oflv0r", + "error": { + "name": "ZodError", + "message": "[\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"key\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"name\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"number\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"tier\"\n ],\n \"message\": \"Invalid input: expected number, received undefined\"\n },\n {\n \"expected\": \"object\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"base\"\n ],\n \"message\": \"Invalid input: expected object, received undefined\"\n }\n]" + }, + "row": { + "id": "cmgqyuj2h000zmofwe8oflv0r", + "key": "bat", + "name": "Murciélago", + "category": null, + "guildId": "1316592320954630144", + "stats": { + "attack": 4 + }, + "drops": null, + "metadata": null, + "createdAt": "2025-10-14T19:39:39.401Z", + "updatedAt": "2025-10-14T19:39:39.401Z" + } + }, + { + "id": "cmgqyuj6r0011mofwii4i80qf", + "error": { + "name": "ZodError", + "message": "[\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"key\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"name\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"number\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"tier\"\n ],\n \"message\": \"Invalid input: expected number, received undefined\"\n },\n {\n \"expected\": \"object\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"base\"\n ],\n \"message\": \"Invalid input: expected object, received undefined\"\n }\n]" + }, + "row": { + "id": "cmgqyuj6r0011mofwii4i80qf", + "key": "slime", + "name": "Slime", + "category": null, + "guildId": "1316592320954630144", + "stats": { + "attack": 6 + }, + "drops": null, + "metadata": null, + "createdAt": "2025-10-14T19:39:39.556Z", + "updatedAt": "2025-10-14T19:39:39.556Z" + } + }, + { + "id": "cmgqyuj8t0013mofwsc965xxk", + "error": { + "name": "ZodError", + "message": "[\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"key\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"name\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"number\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"tier\"\n ],\n \"message\": \"Invalid input: expected number, received undefined\"\n },\n {\n \"expected\": \"object\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"base\"\n ],\n \"message\": \"Invalid input: expected object, received undefined\"\n }\n]" + }, + "row": { + "id": "cmgqyuj8t0013mofwsc965xxk", + "key": "goblin", + "name": "Duende", + "category": null, + "guildId": "1316592320954630144", + "stats": { + "attack": 8 + }, + "drops": null, + "metadata": null, + "createdAt": "2025-10-14T19:39:39.629Z", + "updatedAt": "2025-10-14T19:39:39.629Z" + } + }, + { + "id": "cmgqyunde001zmofwyqw6oh49", + "error": { + "name": "ZodError", + "message": "[\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"key\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"name\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"number\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"tier\"\n ],\n \"message\": \"Invalid input: expected number, received undefined\"\n },\n {\n \"expected\": \"object\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"base\"\n ],\n \"message\": \"Invalid input: expected object, received undefined\"\n }\n]" + }, + "row": { + "id": "cmgqyunde001zmofwyqw6oh49", + "key": "orc", + "name": "Orco", + "category": null, + "guildId": "1316592320954630144", + "stats": { + "attack": 12, + "defense": 2 + }, + "drops": null, + "metadata": null, + "createdAt": "2025-10-14T19:39:44.978Z", + "updatedAt": "2025-10-14T19:39:44.978Z" + } + }, + { + "id": "cmgqyunf90021mofw2nb0j3ye", + "error": { + "name": "ZodError", + "message": "[\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"key\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"name\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"number\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"tier\"\n ],\n \"message\": \"Invalid input: expected number, received undefined\"\n },\n {\n \"expected\": \"object\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"base\"\n ],\n \"message\": \"Invalid input: expected object, received undefined\"\n }\n]" + }, + "row": { + "id": "cmgqyunf90021mofw2nb0j3ye", + "key": "troll", + "name": "Trol", + "category": null, + "guildId": "1316592320954630144", + "stats": { + "attack": 20, + "defense": 4 + }, + "drops": null, + "metadata": null, + "createdAt": "2025-10-14T19:39:45.045Z", + "updatedAt": "2025-10-14T19:39:45.045Z" + } + }, + { + "id": "cmgqyunha0023mofwyhh34uye", + "error": { + "name": "ZodError", + "message": "[\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"key\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"name\"\n ],\n \"message\": \"Invalid input: expected string, received undefined\"\n },\n {\n \"expected\": \"number\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"tier\"\n ],\n \"message\": \"Invalid input: expected number, received undefined\"\n },\n {\n \"expected\": \"object\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"base\"\n ],\n \"message\": \"Invalid input: expected object, received undefined\"\n }\n]" + }, + "row": { + "id": "cmgqyunha0023mofwyhh34uye", + "key": "dragonling", + "name": "Dragoncito", + "category": null, + "guildId": "1316592320954630144", + "stats": { + "attack": 35, + "defense": 6 + }, + "drops": null, + "metadata": null, + "createdAt": "2025-10-14T19:39:45.119Z", + "updatedAt": "2025-10-14T19:39:45.119Z" + } + } +] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 09b0a52..3645576 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "node-appwrite": "19.1.0", "pino": "9.13.0", "prisma": "6.16.2", - "redis": "5.8.2" + "redis": "5.8.2", + "zod": "4.1.12" }, "devDependencies": { "@types/ejs": "^3.1.5", @@ -3463,9 +3464,9 @@ } }, "node_modules/zod": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 2e85000..22349c9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "tsc": "tsc", "typecheck": "tsc --noEmit", "seed:minigames": "npx tsx src/game/minigames/seed.ts", - "test:mobs": "npx tsx scripts/testMobData.ts", + "test:mobs": "npx tsx scripts/testMobData.ts", + "test:unit:mobs": "npx tsx scripts/testMobUnit.ts", + "test:unit": "npx tsx test/unit/rewardMods.unit.ts", "start:optimize-relic": "NODE_ENV=production ENABLE_MEMORY_OPTIMIZER=true NEW_RELIC_APP_NAME=amayo NEW_RELIC_LICENSE_KEY= NODE_OPTIONS='--max-old-space-size=512 --expose-gc' npx tsx --experimental-loader=newrelic/esm-loader.mjs src/main.ts" }, "keywords": [], @@ -36,10 +38,10 @@ "ejs": "^3.1.10", "newrelic": "13.4.0", "node-appwrite": "19.1.0", - "pino": "9.13.0", - "zod": "4.25.1", + "pino": "9.13.0", "prisma": "6.16.2", - "redis": "5.8.2" + "redis": "5.8.2", + "zod": "4.1.12" }, "devDependencies": { "@types/ejs": "^3.1.5", diff --git a/scheduled_mob_attack_backup.json b/scheduled_mob_attack_backup.json new file mode 100644 index 0000000..2b308cb --- /dev/null +++ b/scheduled_mob_attack_backup.json @@ -0,0 +1,42 @@ +[ + { + "id": "cmgqyujes0014mofws1efy1p3", + "userId": "327207082203938818", + "guildId": "1316592320954630144", + "mobId": "cmgqyuj6r0011mofwii4i80qf", + "scheduleAt": "2025-10-14T19:39:44.844Z", + "processedAt": null, + "status": "scheduled", + "metadata": null + }, + { + "id": "cmgqyujes0015mofwyrgzfv6s", + "userId": "327207082203938818", + "guildId": "1316592320954630144", + "mobId": "cmgqyuj6r0011mofwii4i80qf", + "scheduleAt": "2025-10-14T19:39:54.844Z", + "processedAt": null, + "status": "scheduled", + "metadata": null + }, + { + "id": "cmgqyunn50024mofw3ae9ub5k", + "userId": "327207082203938818", + "guildId": "1316592320954630144", + "mobId": "cmgqyunde001zmofwyqw6oh49", + "scheduleAt": "2025-10-14T19:40:10.329Z", + "processedAt": null, + "status": "scheduled", + "metadata": null + }, + { + "id": "cmgqyunn50025mofwnj7xmke5", + "userId": "327207082203938818", + "guildId": "1316592320954630144", + "mobId": "cmgqyunha0023mofwyhh34uye", + "scheduleAt": "2025-10-14T19:40:25.329Z", + "processedAt": null, + "status": "scheduled", + "metadata": null + } +] \ No newline at end of file diff --git a/scripts/cleanInvalidMobs.ts b/scripts/cleanInvalidMobs.ts new file mode 100644 index 0000000..77c5f7b --- /dev/null +++ b/scripts/cleanInvalidMobs.ts @@ -0,0 +1,54 @@ +import { prisma } from "../src/core/database/prisma"; +import { BaseMobDefinitionSchema } from "../src/game/mobs/mobData"; + +async function run() { + if (!process.env.XATA_DB) { + console.error("XATA_DB not set — aborting"); + process.exit(1); + } + console.log("Scanning mobs table for invalid definitions..."); + const rows: any[] = await (prisma as any).mob.findMany(); + const invalid: any[] = []; + for (const r of rows) { + const cfg = + r.metadata ?? + r.stats ?? + r.drops ?? + r.config ?? + r.definition ?? + r.data ?? + null; + try { + BaseMobDefinitionSchema.parse(cfg as any); + } catch (e) { + invalid.push({ id: r.id, error: (e as any)?.errors ?? e, row: r }); + } + } + if (invalid.length === 0) { + console.log("No invalid mob definitions found."); + process.exit(0); + } + console.log( + `Found ${invalid.length} invalid rows. Backing up and deleting...` + ); + // backup + console.log("Backup file: invalid_mobs_backup.json"); + require("fs").writeFileSync( + "invalid_mobs_backup.json", + JSON.stringify(invalid, null, 2) + ); + for (const it of invalid) { + try { + await (prisma as any).mob.delete({ where: { id: it.id } }); + console.log("Deleted invalid mob id=", it.id); + } catch (e) { + console.warn("Failed to delete id=", it.id, e); + } + } + console.log("Cleanup complete. Review invalid_mobs_backup.json"); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/findMobDependencies.ts b/scripts/findMobDependencies.ts new file mode 100644 index 0000000..8f949f9 --- /dev/null +++ b/scripts/findMobDependencies.ts @@ -0,0 +1,80 @@ +import fs from "fs"; +import { prisma } from "../src/core/database/prisma"; + +async function run() { + if (!process.env.XATA_DB) { + console.error("XATA_DB not set — aborting"); + process.exit(1); + } + + if (!fs.existsSync("invalid_mobs_backup.json")) { + console.error("invalid_mobs_backup.json not found — run cleanup first"); + process.exit(1); + } + + const bak = JSON.parse(fs.readFileSync("invalid_mobs_backup.json", "utf8")); + const ids: string[] = bak.map((b: any) => b.id).filter(Boolean); + if (ids.length === 0) { + console.log("No ids found in invalid_mobs_backup.json"); + return; + } + + console.log("Looking for FK constraints that reference the Mob table..."); + const fkSql = ` + SELECT + tc.constraint_name, kcu.table_name, kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.constraint_schema = kcu.constraint_schema + JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema + WHERE tc.constraint_type = 'FOREIGN KEY' AND LOWER(ccu.table_name) = 'mob' + `; + + const refs: any[] = await (prisma as any).$queryRawUnsafe(fkSql); + if (!refs || refs.length === 0) { + console.log("No FK constraints found referencing mob table."); + return; + } + + console.log("Found referencing constraints:"); + for (const r of refs) { + console.log( + ` - ${r.table_name}.${r.column_name} -> ${r.referenced_table}.${r.referenced_column} (constraint ${r.constraint_name})` + ); + } + + // For each referencing table/column, search rows that use our ids + for (const r of refs) { + const table = r.table_name; + const column = r.column_name; + console.log( + `\nChecking table ${table} (column ${column}) for dependent rows...` + ); + for (const id of ids) { + try { + const cntRes: any[] = await (prisma as any).$queryRawUnsafe( + `SELECT COUNT(*) AS cnt FROM "${table}" WHERE "${column}" = '${id}'` + ); + const cnt = + cntRes && cntRes[0] + ? Number(cntRes[0].cnt || cntRes[0].count || 0) + : 0; + console.log(` mob id=${id} -> ${cnt} dependent row(s)`); + if (cnt > 0) { + const rows: any[] = await (prisma as any).$queryRawUnsafe( + `SELECT * FROM "${table}" WHERE "${column}" = '${id}' LIMIT 5` + ); + console.log(" Sample rows:", rows); + } + } catch (e) { + console.warn(" Failed to query", table, column, e?.message ?? e); + } + } + } + + console.log("\nDependency scan complete."); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/fullServerSetup.ts b/scripts/fullServerSetup.ts new file mode 100644 index 0000000..2b771e6 --- /dev/null +++ b/scripts/fullServerSetup.ts @@ -0,0 +1,393 @@ +import { prisma } from "../src/core/database/prisma"; +import { Prisma } from "@prisma/client"; + +/** + * fullServerSetup.ts + * + * Script idempotente para poblar UN servidor con todo lo necesario: + * - Economy items (herramientas, armas, materiales, cofres, pociones) + * - Item recipes (crafteo) + * - Item mutations (encantamientos) + * - Game areas y niveles + * - Mobs con drops + * - Opcional: programar ataques de mobs demo + * + * Uso: provee GUILD_ID como variable de entorno opcional. Si no se provee, usa el id por defecto. + * GUILD_ID=1316592320954630144 npx tsx scripts/fullServerSetup.ts + */ + +const DEFAULT_GUILD = process.env.GUILD_ID ?? "1316592320954630144"; + +async function upsertEconomyItem( + guildId: string | null, + key: string, + data: Omit +) { + const existing = await prisma.economyItem.findFirst({ + where: { key, guildId }, + }); + if (existing) + return prisma.economyItem.update({ + where: { id: existing.id }, + data: { ...data }, + }); + return prisma.economyItem.create({ data: { ...data, key, guildId } }); +} + +async function upsertGameArea( + guildId: string | null, + key: string, + data: Omit +) { + const existing = await prisma.gameArea.findFirst({ where: { key, guildId } }); + if (existing) + return prisma.gameArea.update({ + where: { id: existing.id }, + data: { ...data }, + }); + return prisma.gameArea.create({ data: { ...data, key, guildId } }); +} + +async function upsertMob( + guildId: string | null, + key: string, + data: Omit +) { + const existing = await prisma.mob.findFirst({ where: { key, guildId } }); + if (existing) + return prisma.mob.update({ where: { id: existing.id }, data: { ...data } }); + return prisma.mob.create({ data: { ...data, key, guildId } }); +} + +async function upsertItemRecipe( + guildId: string | null, + productKey: string, + ingredients: { itemKey: string; qty: number }[], + productQty = 1 +) { + // Ensure product exists + const product = await prisma.economyItem.findFirst({ + where: { key: productKey, OR: [{ guildId }, { guildId: null }] }, + orderBy: [{ guildId: "desc" }], + }); + if (!product) throw new Error(`Product item not found: ${productKey}`); + + // Find existing recipe by productItemId + const existing = await prisma.itemRecipe.findUnique({ + where: { productItemId: product.id }, + }); + if (existing) { + // Recreate ingredients set + await prisma.recipeIngredient.deleteMany({ + where: { recipeId: existing.id }, + }); + for (const ing of ingredients) { + const it = await prisma.economyItem.findFirst({ + where: { key: ing.itemKey, OR: [{ guildId }, { guildId: null }] }, + orderBy: [{ guildId: "desc" }], + }); + if (!it) throw new Error(`Ingredient item not found: ${ing.itemKey}`); + await prisma.recipeIngredient.create({ + data: { recipeId: existing.id, itemId: it.id, quantity: ing.qty }, + }); + } + return existing; + } + + const r = await prisma.itemRecipe.create({ + data: { productItemId: product.id, productQuantity: productQty }, + }); + for (const ing of ingredients) { + const it = await prisma.economyItem.findFirst({ + where: { key: ing.itemKey, OR: [{ guildId }, { guildId: null }] }, + orderBy: [{ guildId: "desc" }], + }); + if (!it) throw new Error(`Ingredient item not found: ${ing.itemKey}`); + await prisma.recipeIngredient.create({ + data: { recipeId: r.id, itemId: it.id, quantity: ing.qty }, + }); + } + return r; +} + +export async function runFullServerSetup( + guildIdArg?: string | null, + options?: { dryRun?: boolean } +) { + const guildId = guildIdArg ?? DEFAULT_GUILD; + console.log("Starting full server setup for guild=", guildId, options ?? {}); + + // --- Items: tools, weapons, materials --- + await upsertEconomyItem(guildId, "tool.pickaxe.basic", { + name: "Pico Básico", + stackable: false, + props: { + tool: { type: "pickaxe", tier: 1 }, + breakable: { enabled: true, maxDurability: 100, durabilityPerUse: 5 }, + } as unknown as Prisma.InputJsonValue, + tags: ["tool", "mine"], + }); + + await upsertEconomyItem(guildId, "tool.pickaxe.iron", { + name: "Pico de Hierro", + stackable: false, + props: { + tool: { type: "pickaxe", tier: 2 }, + breakable: { enabled: true, maxDurability: 180, durabilityPerUse: 4 }, + } as unknown as Prisma.InputJsonValue, + tags: ["tool", "mine", "tier2"], + }); + + await upsertEconomyItem(guildId, "weapon.sword.iron", { + name: "Espada de Hierro", + stackable: false, + props: { + damage: 10, + tool: { type: "sword", tier: 1 }, + breakable: { enabled: true, maxDurability: 150, durabilityPerUse: 2 }, + } as unknown as Prisma.InputJsonValue, + tags: ["weapon"], + }); + + await upsertEconomyItem(guildId, "armor.leather.basic", { + name: "Armadura de Cuero", + stackable: false, + props: { defense: 3 } as unknown as Prisma.InputJsonValue, + tags: ["armor"], + }); + + await upsertEconomyItem(guildId, "ore.iron", { + name: "Mineral de Hierro", + stackable: true, + props: { craftingOnly: true } as unknown as Prisma.InputJsonValue, + tags: ["ore", "common"], + }); + await upsertEconomyItem(guildId, "ingot.iron", { + name: "Lingote de Hierro", + stackable: true, + props: {} as unknown as Prisma.InputJsonValue, + tags: ["ingot", "metal"], + }); + + // Consumibles y pociones + await upsertEconomyItem(guildId, "food.meat.small", { + name: "Carne Pequeña", + stackable: true, + props: { + food: { healHp: 8, cooldownSeconds: 20 }, + } as unknown as Prisma.InputJsonValue, + tags: ["food"], + }); + await upsertEconomyItem(guildId, "potion.energy", { + name: "Poción Energética", + stackable: true, + props: { + potion: { removeEffects: ["FATIGUE"], cooldownSeconds: 90 }, + } as unknown as Prisma.InputJsonValue, + tags: ["potion", "utility"], + }); + + // Cofre con recompensas + await upsertEconomyItem(guildId, "chest.daily", { + name: "Cofre Diario", + stackable: true, + props: { + chest: { + enabled: true, + consumeOnOpen: true, + randomMode: "single", + rewards: [ + { type: "coins", amount: 200 }, + { type: "item", itemKey: "ingot.iron", qty: 2 }, + ], + }, + } as unknown as Prisma.InputJsonValue, + tags: ["chest"], + }); + + // --- Mutations / enchants catalog --- + // Item mutations (catalog) + const existingRuby = await prisma.itemMutation.findFirst({ + where: { key: "ruby_core", guildId }, + }); + if (existingRuby) { + await prisma.itemMutation.update({ + where: { id: existingRuby.id }, + data: { name: "Núcleo de Rubí", effects: { damageBonus: 15 } as any }, + }); + } else { + await prisma.itemMutation.create({ + data: { + key: "ruby_core", + name: "Núcleo de Rubí", + guildId, + effects: { damageBonus: 15 } as any, + } as any, + }); + } + + const existingEmerald = await prisma.itemMutation.findFirst({ + where: { key: "emerald_core", guildId }, + }); + if (existingEmerald) { + await prisma.itemMutation.update({ + where: { id: existingEmerald.id }, + data: { + name: "Núcleo de Esmeralda", + effects: { defenseBonus: 10, maxHpBonus: 20 } as any, + }, + }); + } else { + await prisma.itemMutation.create({ + data: { + key: "emerald_core", + name: "Núcleo de Esmeralda", + guildId, + effects: { defenseBonus: 10, maxHpBonus: 20 } as any, + } as any, + }); + } + + // --- Recipes (crafteo): iron_ingot <- iron ore x3 + // Create ingredient items if missing + await upsertEconomyItem(guildId, "ingot.iron", { + name: "Lingote de Hierro", + stackable: true, + props: {} as unknown as Prisma.InputJsonValue, + tags: ["ingot"], + }); + await upsertEconomyItem(guildId, "ore.iron", { + name: "Mineral de Hierro", + stackable: true, + props: {} as unknown as Prisma.InputJsonValue, + tags: ["ore"], + }); + await upsertItemRecipe( + guildId, + "ingot.iron", + [{ itemKey: "ore.iron", qty: 3 }], + 1 + ); + + // --- Areas & Levels --- + const mine = await upsertGameArea(guildId, "mine.cavern", { + name: "Mina: Caverna", + type: "MINE", + config: { cooldownSeconds: 10 } as unknown as Prisma.InputJsonValue, + }); + await prisma.gameAreaLevel.upsert({ + where: { areaId_level: { areaId: mine.id, level: 1 } }, + update: {}, + create: { + areaId: mine.id, + level: 1, + requirements: { + tool: { required: true, toolType: "pickaxe", minTier: 1 }, + } as any, + rewards: { + draws: 2, + table: [ + { type: "item", itemKey: "ore.iron", qty: 2, weight: 70 }, + { type: "coins", amount: 10, weight: 30 }, + ], + } as any, + mobs: { + draws: 1, + table: [ + { mobKey: "slime.green", weight: 50 }, + { mobKey: "bat", weight: 50 }, + ], + } as any, + }, + }); + + const lagoon = await upsertGameArea(guildId, "lagoon.shore", { + name: "Laguna: Orilla", + type: "LAGOON", + config: { cooldownSeconds: 12 } as unknown as Prisma.InputJsonValue, + }); + await prisma.gameAreaLevel.upsert({ + where: { areaId_level: { areaId: lagoon.id, level: 1 } }, + update: {}, + create: { + areaId: lagoon.id, + level: 1, + requirements: { + tool: { required: true, toolType: "rod", minTier: 1 }, + } as any, + rewards: { + draws: 2, + table: [ + { type: "item", itemKey: "food.meat.small", qty: 1, weight: 70 }, + { type: "coins", amount: 10, weight: 30 }, + ], + } as any, + mobs: { draws: 0, table: [] } as any, + }, + }); + + // --- Basic mobs --- + await upsertMob(guildId, "slime.green", { + name: "Slime Verde", + stats: { attack: 4, hp: 18 } as any, + drops: [{ itemKey: "ingot.iron", qty: 1, weight: 10 }] as any, + }); + await upsertMob(guildId, "bat", { + name: "Murciélago", + stats: { attack: 3, hp: 10 } as any, + drops: Prisma.DbNull, + }); + + // Advanced mobs + await upsertMob(guildId, "goblin", { + name: "Duende", + stats: { attack: 8, hp: 30 } as any, + drops: [{ itemKey: "ore.iron", qty: 1, weight: 50 }] as any, + }); + await upsertMob(guildId, "orc", { + name: "Orco", + stats: { attack: 12, hp: 50 } as any, + drops: Prisma.DbNull, + }); + + // Programar un par de ataques demo (opcional) + const targetUser = process.env.TARGET_USER ?? null; + if (targetUser) { + const slime = await prisma.mob.findFirst({ + where: { key: "slime.green", OR: [{ guildId }, { guildId: null }] }, + orderBy: [{ guildId: "desc" }], + }); + if (slime) { + const now = Date.now(); + await prisma.scheduledMobAttack.createMany({ + data: [ + { + userId: targetUser, + guildId: guildId ?? "global", + mobId: slime.id, + scheduleAt: new Date(now + 5_000), + }, + { + userId: targetUser, + guildId: guildId ?? "global", + mobId: slime.id, + scheduleAt: new Date(now + 15_000), + }, + ], + }); + } + } + + console.log("Full server setup complete."); +} + +// Backwards-compatible CLI entry +if (require.main === module) { + const gid = process.env.GUILD_ID ?? DEFAULT_GUILD; + runFullServerSetup(gid) + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }); +} diff --git a/scripts/removeInvalidMobsWithDeps.ts b/scripts/removeInvalidMobsWithDeps.ts new file mode 100644 index 0000000..a1201d1 --- /dev/null +++ b/scripts/removeInvalidMobsWithDeps.ts @@ -0,0 +1,72 @@ +import fs from "fs"; +import { prisma } from "../src/core/database/prisma"; + +async function run() { + if (!process.env.XATA_DB) { + console.error("XATA_DB not set — aborting"); + process.exit(1); + } + + if (!fs.existsSync("invalid_mobs_backup.json")) { + console.error("invalid_mobs_backup.json not found — run cleanup first"); + process.exit(1); + } + + const bak = JSON.parse(fs.readFileSync("invalid_mobs_backup.json", "utf8")); + const ids: string[] = bak.map((b: any) => b.id).filter(Boolean); + if (ids.length === 0) { + console.log("No ids found in invalid_mobs_backup.json"); + return; + } + + console.log( + "Backing up ScheduledMobAttack rows that reference these mob ids..." + ); + try { + const deps = await (prisma as any).scheduledMobAttack.findMany({ + where: { mobId: { in: ids } }, + }); + fs.writeFileSync( + "scheduled_mob_attack_backup.json", + JSON.stringify(deps, null, 2) + ); + console.log( + `Backed up ${deps.length} ScheduledMobAttack rows to scheduled_mob_attack_backup.json` + ); + + if (deps.length > 0) { + console.log( + "Deleting ScheduledMobAttack rows referencing invalid mobs..." + ); + const delRes = await (prisma as any).scheduledMobAttack.deleteMany({ + where: { mobId: { in: ids } }, + }); + console.log(`Deleted ${delRes.count || delRes} ScheduledMobAttack rows`); + } else { + console.log("No dependent ScheduledMobAttack rows to delete."); + } + } catch (e: any) { + console.error("Failed to backup/delete dependent rows:", e?.message ?? e); + process.exit(1); + } + + console.log("Deleting invalid mob rows from Mob table..."); + try { + const delMobs = await (prisma as any).mob.deleteMany({ + where: { id: { in: ids } }, + }); + console.log(`Deleted ${delMobs.count || delMobs} mob rows`); + } catch (e: any) { + console.error("Failed to delete mob rows:", e?.message ?? e); + process.exit(1); + } + + console.log( + "Done. Backups: invalid_mobs_backup.json, scheduled_mob_attack_backup.json" + ); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/testMobUnit.ts b/scripts/testMobUnit.ts new file mode 100644 index 0000000..8bf659e --- /dev/null +++ b/scripts/testMobUnit.ts @@ -0,0 +1,54 @@ +import assert from "assert"; +import { + computeMobStats, + getMobInstance, + MOB_DEFINITIONS, +} from "../src/game/mobs/mobData"; +import { createOrUpdateMob } from "../src/game/mobs/admin"; + +async function run() { + console.log("Running mob unit tests..."); + + // computeMobStats basic + const def = MOB_DEFINITIONS[0]; + const statsLv1 = computeMobStats(def, 1); + assert(typeof statsLv1.hp === "number", "hp should be number"); + assert(typeof statsLv1.attack === "number", "attack should be number"); + + // scaling test + const statsLv5 = computeMobStats(def, 5); + if ((def.scaling && def.scaling.hpPerLevel) || 0) { + assert(statsLv5.hp >= statsLv1.hp, "hp should not decrease with level"); + } + + console.log("computeMobStats: OK"); + + // getMobInstance basic + const key = def.key; + const inst = getMobInstance(key, 3); + assert(inst !== null, "getMobInstance should return instance"); + assert(inst!.scaled.hp > 0, "instance hp > 0"); + console.log("getMobInstance: OK"); + + // createOrUpdateMob (non-DB mode should return def) + try { + const res = await createOrUpdateMob({ + ...def, + key: "unit.test.mob", + } as any); + if (!res || !res.def) throw new Error("createOrUpdateMob returned invalid"); + console.log("createOrUpdateMob: OK (no-DB mode)"); + } catch (e) { + console.warn( + "createOrUpdateMob: skipped (DB may be required) -", + (e as any)?.message ?? e + ); + } + + console.log("All mob unit tests passed."); +} + +run().catch((e) => { + console.error("Tests failed:", e); + process.exit(1); +}); diff --git a/scripts/testRewardMods.ts b/scripts/testRewardMods.ts new file mode 100644 index 0000000..d017a87 --- /dev/null +++ b/scripts/testRewardMods.ts @@ -0,0 +1,66 @@ +import { + initializeMobRepository, + getMobInstance, + listMobKeys, +} from "../src/game/mobs/mobData"; +import { runMinigame } from "../src/game/minigames/service"; +import { prisma } from "../src/core/database/prisma"; + +async function run() { + console.log("Initializing mob repository..."); + await initializeMobRepository(); + console.log("Available mob keys:", listMobKeys()); + + // Mock user/guild for smoke (these should exist in your test DB or the functions will create wallet entries etc.) + const userId = "test-user"; + const guildId = "test-guild"; + + try { + console.log("Ensuring minimal game area 'mine.cavern' exists..."); + // create minimal area and level if not present + let area = await prisma.gameArea.findFirst({ + where: { key: "mine.cavern", OR: [{ guildId }, { guildId: null }] }, + orderBy: [{ guildId: "desc" }], + }); + if (!area) { + area = await prisma.gameArea.create({ + data: { + key: "mine.cavern", + guildId: null, + name: "Cavern of Tests", + type: "MINE", + config: {}, + metadata: {}, + } as any, + }); + } + let lvl = await prisma.gameAreaLevel.findFirst({ + where: { areaId: area.id, level: 1 }, + }); + if (!lvl) { + lvl = await prisma.gameAreaLevel.create({ + data: { + areaId: area.id, + level: 1, + requirements: {} as any, + rewards: { + draws: 1, + table: [{ type: "coins", amount: 5, weight: 1 }], + } as any, + mobs: { draws: 0, table: [] } as any, + } as any, + }); + } + + console.log("Running minigame mine.cavern level 1 as smoke test..."); + const res = await runMinigame(userId, guildId, "mine.cavern", 1); + console.log("Minigame result:", JSON.stringify(res, null, 2)); + } catch (e) { + console.error("runMinigame failed:", e); + } +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/commands/messages/admin/mobsLista.ts b/src/commands/messages/admin/mobsLista.ts index 6966073..c381d81 100644 --- a/src/commands/messages/admin/mobsLista.ts +++ b/src/commands/messages/admin/mobsLista.ts @@ -1,6 +1,5 @@ import type { CommandMessage } from "../../../core/types/commands"; import type Amayo from "../../../core/client"; -import { prisma } from "../../../core/database/prisma"; import { ComponentType, ButtonStyle } from "discord-api-types/v10"; import type { MessageComponentInteraction, TextBasedChannel } from "discord.js"; diff --git a/src/commands/messages/game/areaCreate.ts b/src/commands/messages/game/areaCreate.ts index ab5c0bc..c00184c 100644 --- a/src/commands/messages/game/areaCreate.ts +++ b/src/commands/messages/game/areaCreate.ts @@ -1,9 +1,19 @@ -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, TextBasedChannel } 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, + TextBasedChannel, +} from "discord.js"; +import { + ComponentType, + TextInputStyle, + ButtonStyle, +} from "discord-api-types/v10"; interface AreaState { key: string; @@ -14,43 +24,43 @@ interface AreaState { } function buildAreaDisplay(state: AreaState, editing: boolean = false) { - const title = editing ? 'Editando Área' : 'Creando Área'; + const title = editing ? "Editando Área" : "Creando Área"; const statusText = [ - '**📋 Estado Actual:**', - `**Nombre:** ${state.name || '❌ No configurado'}`, - `**Tipo:** ${state.type || '❌ No configurado'}`, + "**📋 Estado Actual:**", + `**Nombre:** ${state.name || "❌ No configurado"}`, + `**Tipo:** ${state.type || "❌ No configurado"}`, `**Config:** ${Object.keys(state.config || {}).length} campos`, - `**Metadata:** ${Object.keys(state.metadata || {}).length} campos` - ].join('\n'); + `**Metadata:** ${Object.keys(state.metadata || {}).length} campos`, + ].join("\n"); const instructionsText = [ - '**🎮 Instrucciones:**', - '• **Base**: Configura nombre y tipo', - '• **Config (JSON)**: Configuración técnica', - '• **Meta (JSON)**: Metadatos adicionales', - '• **Guardar**: Confirma los cambios', - '• **Cancelar**: Descarta los cambios' - ].join('\n'); + "**🎮 Instrucciones:**", + "• **Base**: Configura nombre y tipo", + "• **Config (JSON)**: Configuración técnica", + "• **Meta (JSON)**: Metadatos adicionales", + "• **Guardar**: Confirma los cambios", + "• **Cancelar**: Descarta los cambios", + ].join("\n"); return { type: 17, - accent_color: 0x00FF00, + accent_color: 0x00ff00, components: [ { type: 10, - content: `# 🗺️ ${title}: \`${state.key}\`` + content: `# 🗺️ ${title}: \`${state.key}\``, }, { type: 14, divider: true }, { type: 10, - content: statusText + content: statusText, }, { type: 14, divider: true }, { type: 10, - content: instructionsText - } - ] + content: instructionsText, + }, + ], }; } @@ -59,38 +69,73 @@ const buildEditorComponents = (state: AreaState, editing: boolean = false) => [ { type: 1, components: [ - { type: 2, style: ButtonStyle.Primary, label: 'Base', custom_id: 'ga_base' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Config (JSON)', custom_id: 'ga_config' }, - { type: 2, style: ButtonStyle.Secondary, label: 'Meta (JSON)', custom_id: 'ga_meta' }, - { type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'ga_save' }, - { type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'ga_cancel' }, - ] - } + { + type: 2, + style: ButtonStyle.Primary, + label: "Base", + custom_id: "ga_base", + }, + { + type: 2, + style: ButtonStyle.Secondary, + label: "Config (JSON)", + custom_id: "ga_config", + }, + { + type: 2, + style: ButtonStyle.Secondary, + label: "Meta (JSON)", + custom_id: "ga_meta", + }, + { + type: 2, + style: ButtonStyle.Success, + label: "Guardar", + custom_id: "ga_save", + }, + { + type: 2, + style: ButtonStyle.Danger, + label: "Cancelar", + custom_id: "ga_cancel", + }, + ], + }, ]; export const command: CommandMessage = { - name: 'area-crear', - type: 'message', - aliases: ['crear-area','areacreate'], + name: "area-crear", + type: "message", + aliases: ["crear-area", "areacreate"], cooldown: 10, - description: 'Crea una GameArea (mina/laguna/arena/farm) para este servidor con editor.', - usage: 'area-crear ', + description: + "Crea una GameArea (mina/laguna/arena/farm) para este servidor con editor.", + usage: "area-crear ", run: async (message, args, _client: Amayo) => { const channel = message.channel as TextBasedChannel & { send: Function }; - const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, prisma); + const allowed = await hasManageGuildOrStaff( + message.member, + message.guild!.id, + prisma + ); if (!allowed) { await (channel.send as any)({ content: null, flags: 32768, - components: [{ - type: 17, - accent_color: 0xFF0000, - components: [{ - type: 10, - content: '❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.' - }] - }], - reply: { messageReference: message.id } + components: [ + { + type: 17, + accent_color: 0xff0000, + components: [ + { + type: 10, + content: + "❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.", + }, + ], + }, + ], + reply: { messageReference: message.id }, }); return; } @@ -100,15 +145,20 @@ export const command: CommandMessage = { await (channel.send as any)({ content: null, flags: 32768, - components: [{ - type: 17, - accent_color: 0xFFA500, - components: [{ - type: 10, - content: '⚠️ **Uso Incorrecto**\n└ Uso: `!area-crear `' - }] - }], - reply: { messageReference: message.id } + components: [ + { + type: 17, + accent_color: 0xffa500, + components: [ + { + type: 10, + content: + "⚠️ **Uso Incorrecto**\n└ Uso: `!area-crear `", + }, + ], + }, + ], + reply: { messageReference: message.id }, }); return; } @@ -119,15 +169,20 @@ export const command: CommandMessage = { await (channel.send as any)({ content: null, flags: 32768, - components: [{ - type: 17, - accent_color: 0xFF0000, - components: [{ - type: 10, - content: '❌ **Área Ya Existe**\n└ Ya existe un área con esa key en este servidor.' - }] - }], - reply: { messageReference: message.id } + components: [ + { + type: 17, + accent_color: 0xff0000, + components: [ + { + type: 10, + content: + "❌ **Área Ya Existe**\n└ Ya existe un área con esa key en este servidor.", + }, + ], + }, + ], + reply: { messageReference: message.id }, }); return; } @@ -138,133 +193,271 @@ export const command: CommandMessage = { content: null, flags: 32768, components: buildEditorComponents(state, false), - reply: { messageReference: message.id } + reply: { messageReference: message.id }, }); - const collector = editorMsg.createMessageComponentCollector({ time: 30*60_000, filter: (i)=> i.user.id === message.author.id }); - collector.on('collect', async (i: MessageComponentInteraction) => { + const collector = editorMsg.createMessageComponentCollector({ + time: 30 * 60_000, + filter: (i) => i.user.id === message.author.id, + }); + collector.on("collect", async (i: MessageComponentInteraction) => { try { if (!i.isButton()) return; switch (i.customId) { - case 'ga_cancel': + case "ga_cancel": await i.deferUpdate(); await editorMsg.edit({ content: null, flags: 32768, - components: [{ - type: 17, - accent_color: 0xFF0000, - components: [{ - type: 10, - content: '**❌ Editor de Área cancelado.**' - }] - }] + components: [ + { + type: 17, + accent_color: 0xff0000, + components: [ + { + type: 10, + content: "**❌ Editor de Área cancelado.**", + }, + ], + }, + ], }); - collector.stop('cancel'); + collector.stop("cancel"); return; - case 'ga_base': - await showBaseModal(i as ButtonInteraction, state, editorMsg, false); + case "ga_base": + await showBaseModal( + i as ButtonInteraction, + state, + editorMsg, + false + ); return; - case 'ga_config': - await showJsonModal(i as ButtonInteraction, state, 'config', 'Config del Área', editorMsg, false); + case "ga_config": + await showJsonModal( + i as ButtonInteraction, + state, + "config", + "Config del Área", + editorMsg, + false + ); return; - case 'ga_meta': - await showJsonModal(i as ButtonInteraction, state, 'metadata', 'Meta del Área', editorMsg, false); + case "ga_meta": + await showJsonModal( + i as ButtonInteraction, + state, + "metadata", + "Meta del Área", + editorMsg, + false + ); return; - case 'ga_save': - if (!state.name || !state.type) { await i.reply({ content: '❌ Completa Base (nombre/tipo).', flags: MessageFlags.Ephemeral }); return; } - await prisma.gameArea.create({ data: { guildId, key: state.key, name: state.name!, type: state.type!, config: state.config ?? {}, metadata: state.metadata ?? {} } }); - await i.reply({ content: '✅ Área guardada.', flags: MessageFlags.Ephemeral }); + case "ga_save": + if (!state.name || !state.type) { + await i.reply({ + content: "❌ Completa Base (nombre/tipo).", + flags: MessageFlags.Ephemeral, + }); + return; + } + await prisma.gameArea.create({ + data: { + guildId, + key: state.key, + name: state.name!, + type: state.type!, + config: state.config ?? {}, + metadata: state.metadata ?? {}, + }, + }); + await i.reply({ + content: "✅ Área guardada.", + flags: MessageFlags.Ephemeral, + }); await editorMsg.edit({ content: null, flags: 32768, - components: [{ - type: 17, - accent_color: 0x00FF00, - components: [{ - type: 10, - content: `**✅ Área \`${state.key}\` creada exitosamente.**` - }] - }] + components: [ + { + type: 17, + accent_color: 0x00ff00, + components: [ + { + type: 10, + content: `**✅ Área \`${state.key}\` creada exitosamente.**`, + }, + ], + }, + ], }); - collector.stop('saved'); + 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,r)=> { - if (r==='time') { + collector.on("end", async (_c, r) => { + if (r === "time") { try { await editorMsg.edit({ content: null, flags: 32768, - components: [{ - type: 17, - accent_color: 0xFFA500, - components: [{ - type: 10, - content: '**⏰ Editor expirado.**' - }] - }] + components: [ + { + type: 17, + accent_color: 0xffa500, + components: [ + { + type: 10, + content: "**⏰ Editor expirado.**", + }, + ], + }, + ], }); } catch {} } }); - } + }, }; -async function showBaseModal(i: ButtonInteraction, state: AreaState, editorMsg: Message, editing: boolean) { - const modal = { title: 'Base del Área', customId: 'ga_base_modal', components: [ - { type: ComponentType.Label, label: 'Nombre', component: { type: ComponentType.TextInput, customId: 'name', style: TextInputStyle.Short, required: true, value: state.name ?? '' } }, - { type: ComponentType.Label, label: 'Tipo (MINE/LAGOON/FIGHT/FARM)', component: { type: ComponentType.TextInput, customId: 'type', style: TextInputStyle.Short, required: true, value: state.type ?? '' } }, - ] } as const; +async function showBaseModal( + i: ButtonInteraction, + state: AreaState, + editorMsg: Message, + editing: boolean +) { + const modal = { + title: "Base del Área", + customId: "ga_base_modal", + components: [ + { + type: ComponentType.Label, + label: "Nombre", + component: { + type: ComponentType.TextInput, + customId: "name", + style: TextInputStyle.Short, + required: true, + value: state.name ?? "", + }, + }, + { + type: ComponentType.Label, + label: "Tipo (MINE/LAGOON/FIGHT/FARM)", + component: { + type: ComponentType.TextInput, + customId: "type", + style: TextInputStyle.Short, + required: true, + value: state.type ?? "", + }, + }, + { + type: ComponentType.Label, + label: "Imagen de referencia (URL, opcional)", + component: { + type: ComponentType.TextInput, + customId: "referenceImage", + style: TextInputStyle.Short, + required: false, + value: + (state.metadata && + (state.metadata.referenceImage || + state.metadata.image || + state.metadata.previewImage)) ?? + "", + }, + }, + ], + } as const; await i.showModal(modal); try { const sub = await i.awaitModalSubmit({ time: 300_000 }); - state.name = sub.components.getTextInputValue('name').trim(); - state.type = sub.components.getTextInputValue('type').trim().toUpperCase(); - await sub.reply({ content: '✅ Base actualizada.', flags: MessageFlags.Ephemeral }); - + state.name = sub.components.getTextInputValue("name").trim(); + state.type = sub.components.getTextInputValue("type").trim().toUpperCase(); + try { + const ref = sub.components.getTextInputValue("referenceImage")?.trim(); + if (ref && ref.length > 0) { + state.metadata = state.metadata || {}; + // store as referenceImage for consumers; renderer looks at previewImage/image/referenceImage + (state.metadata as any).referenceImage = ref; + } + } catch {} + await sub.reply({ + content: "✅ Base actualizada.", + flags: MessageFlags.Ephemeral, + }); + // Actualizar display await editorMsg.edit({ content: null, flags: 32768, - components: buildEditorComponents(state, editing) + components: buildEditorComponents(state, editing), }); } catch {} } -async function showJsonModal(i: ButtonInteraction, state: AreaState, field: 'config'|'metadata', title: string, editorMsg: Message, editing: boolean) { +async function showJsonModal( + i: ButtonInteraction, + state: AreaState, + field: "config" | "metadata", + title: string, + editorMsg: Message, + editing: boolean +) { const current = JSON.stringify(state[field] ?? {}); - const modal = { title, customId: `ga_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: `ga_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'); + const raw = sub.components.getTextInputValue("json"); if (raw) { try { state[field] = JSON.parse(raw); - await sub.reply({ content: '✅ Guardado.', flags: MessageFlags.Ephemeral }); + await sub.reply({ + content: "✅ Guardado.", + flags: MessageFlags.Ephemeral, + }); } catch { - await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral }); + await sub.reply({ + content: "❌ JSON inválido.", + flags: MessageFlags.Ephemeral, + }); return; } } else { state[field] = {}; - await sub.reply({ content: 'ℹ️ Limpio.', flags: MessageFlags.Ephemeral }); + await sub.reply({ content: "ℹ️ Limpio.", flags: MessageFlags.Ephemeral }); } - + // Actualizar display await editorMsg.edit({ content: null, flags: 32768, - components: buildEditorComponents(state, editing) + components: buildEditorComponents(state, editing), }); } catch {} } - diff --git a/src/commands/messages/game/mobDelete.ts b/src/commands/messages/game/mobDelete.ts new file mode 100644 index 0000000..8f1a37f --- /dev/null +++ b/src/commands/messages/game/mobDelete.ts @@ -0,0 +1,178 @@ +import { + Message, + MessageFlags, + MessageComponentInteraction, + ButtonInteraction, + TextBasedChannel, +} from "discord.js"; +import { ButtonStyle, ComponentType } from "discord-api-types/v10"; +import type { CommandMessage } from "../../../core/types/commands"; +import { hasManageGuildOrStaff } from "../../../core/lib/permissions"; +import logger from "../../../core/lib/logger"; +import type Amayo from "../../../core/client"; +import { promptKeySelection } from "./_helpers"; + +export const command: CommandMessage = { + name: "mob-eliminar", + type: "message", + aliases: ["eliminar-mob", "mobdelete"], + cooldown: 10, + description: "Elimina un mob del servidor (requiere permisos de staff)", + category: "Minijuegos", + usage: "mob-eliminar", + run: async (message: Message, _args: string[], client: Amayo) => { + const channel = message.channel as TextBasedChannel & { send: Function }; + const allowed = await hasManageGuildOrStaff( + message.member, + message.guild!.id, + client.prisma + ); + if (!allowed) { + await channel.send({ + content: undefined, + flags: 32768, + components: [ + { + type: 17, + accent_color: 0xff0000, + components: [ + { + type: 10, + content: + "❌ No tienes permisos de ManageGuild ni rol de staff.", + }, + ], + }, + ], + }); + return; + } + + const guildId = message.guild!.id; + try { + const { listMobsWithRows } = await import("../../../game/mobs/admin.js"); + const all = await listMobsWithRows(); + const localEntries = all.filter( + (e: any) => e.guildId === guildId && e.id + ); + const selection = await promptKeySelection(message, { + entries: localEntries, + customIdPrefix: "mob_delete", + title: "Selecciona un mob para eliminar", + emptyText: "⚠️ No hay mobs locales configurados.", + placeholder: "Elige un mob…", + filterHint: "Filtra por nombre, key o categoría.", + getOption: (entry: any) => ({ + value: entry.id ?? entry.def.key, + label: entry.def.name ?? entry.def.key, + description: [entry.def?.category ?? "Sin categoría", entry.def.key] + .filter(Boolean) + .join(" • "), + }), + }); + + if (!selection.entry) return; + const entry = selection.entry as any; + + // confirm + const confirmMsg = await channel.send({ + content: `¿Eliminar mob \`${ + entry.def.name || entry.def.key + }\`? Esta acción es irreversible.`, + components: [ + { + type: 1, + components: [ + { + type: 2, + style: ButtonStyle.Danger, + label: "Confirmar", + custom_id: "confirm_delete", + }, + { + type: 2, + style: ButtonStyle.Secondary, + label: "Cancelar", + custom_id: "cancel_delete", + }, + ], + }, + ], + }); + + const collector = confirmMsg.createMessageComponentCollector({ + time: 60_000, + filter: (i) => i.user.id === message.author.id, + }); + collector.on("collect", async (i: MessageComponentInteraction) => { + try { + if (!i.isButton()) return; + if (i.customId === "cancel_delete") { + await i.update({ content: "❌ Cancelado.", components: [] }); + collector.stop("cancel"); + return; + } + if (i.customId === "confirm_delete") { + await i.deferUpdate(); + try { + const { deleteMob } = await import("../../../game/mobs/admin.js"); + const ok = await deleteMob(entry.def.key); + if (ok) { + await i.followUp({ + content: "✅ Mob eliminado.", + flags: MessageFlags.Ephemeral, + }); + try { + await confirmMsg.edit({ + content: "✅ Eliminado.", + components: [], + }); + } catch {} + } else { + // fallback to direct Prisma delete by id + await client.prisma.mob.delete({ where: { id: entry.id } }); + await i.followUp({ + content: "✅ Mob eliminado (fallback).", + flags: MessageFlags.Ephemeral, + }); + try { + await confirmMsg.edit({ + content: "✅ Eliminado (fallback).", + components: [], + }); + } catch {} + } + } catch (e: any) { + // If FK prevents deletion, inform user and suggest running cleanup script + const msg = (e && e.message) || String(e); + await i.followUp({ + content: `❌ No se pudo eliminar: ${msg}`, + flags: MessageFlags.Ephemeral, + }); + } + collector.stop("done"); + return; + } + } catch (err) { + logger.error({ err }, "mob-eliminar"); + } + }); + collector.on("end", async (_c, reason) => { + if (reason === "time") { + try { + await confirmMsg.edit({ + content: "⏰ Confirmación expirada.", + components: [], + }); + } catch {} + } + }); + } catch (e) { + logger.error({ e }, "mob-eliminar"); + await channel.send({ + content: "❌ Error al intentar eliminar mob.", + flags: 32768, + }); + } + }, +}; diff --git a/src/commands/messages/game/setup.ts b/src/commands/messages/game/setup.ts new file mode 100644 index 0000000..01afaf6 --- /dev/null +++ b/src/commands/messages/game/setup.ts @@ -0,0 +1,159 @@ +import { Message, TextBasedChannel } from "discord.js"; +import type { CommandMessage } from "../../../core/types/commands"; +import { hasManageGuildOrStaff } from "../../../core/lib/permissions"; +import fs from "fs"; +import path from "path"; +import logger from "../../../core/lib/logger"; +import type Amayo from "../../../core/client"; + +// Helper: split text into chunks under Discord message limit (~2000) +function chunkText(text: string, size = 1900) { + const parts: string[] = []; + let i = 0; + while (i < text.length) { + parts.push(text.slice(i, i + size)); + i += size; + } + return parts; +} + +export const command: CommandMessage = { + name: "setup", + type: "message", + aliases: ["setup-ejemplos", "setup-demo"], + cooldown: 10, + description: + "Publica ejemplos básicos y avanzados para configurar items, mobs y áreas.", + category: "Admin", + usage: "setup [advanced]", + run: async (message: Message, args: string[], client: Amayo) => { + const channel = message.channel as TextBasedChannel & { send: Function }; + const allowed = await hasManageGuildOrStaff( + message.member, + message.guild!.id, + client.prisma + ); + if (!allowed) { + await channel.send({ + content: "❌ No tienes permisos de ManageGuild ni rol de staff.", + }); + return; + } + + const showAdvanced = args[0] === "advanced" || args.includes("advanced"); + const doInit = args[0] === "init" || args.includes("init"); + const doInitFull = args[0] === "init-full" || args.includes("init-full"); + const initAdvanced = args.includes("advanced") && doInit; + if (doInitFull) { + await channel.send( + "Iniciando FULL setup: creando items, areas, mobs y recetas (modo idempotente). Esto puede tardar unos segundos." + ); + try { + const setupMod: any = await import( + "../../../../scripts/fullServerSetup.js" + ); + if (typeof setupMod.runFullServerSetup === "function") { + // Use guild id from the current guild context + await setupMod.runFullServerSetup(message.guild!.id); + await channel.send("✅ Full setup completado."); + } else { + await channel.send( + "❌ El módulo de setup completo no exporta runFullServerSetup()." + ); + } + } catch (e) { + logger.error({ e }, "setup init-full failed"); + await channel.send( + `❌ Error corriendo fullServerSetup: ${ + (e && (e as any).message) || e + }` + ); + } + return; + } + if (doInit) { + // Run seed logic in-process by importing the seed script module + await channel.send( + "Iniciando setup: creando items, areas, mobs y recetas... Esto puede tardar unos segundos." + ); + try { + const seedMod: any = await import("../../../game/minigames/seed.js"); + if (typeof seedMod.main === "function") { + await seedMod.main(); + await channel.send("✅ Setup inicial completado."); + } else { + // fallback: try executing default export or module itself + if (typeof seedMod === "function") { + await seedMod(); + await channel.send("✅ Setup inicial completado (fallback)."); + } else { + await channel.send( + "❌ Módulo seed no expone main(). Ejecuta el seed manualmente." + ); + } + } + } catch (e) { + logger.error({ e }, "setup init failed"); + await channel.send( + `❌ Error corriendo seed: ${(e && (e as any).message) || e}` + ); + } + return; + } + try { + const readPath = path.resolve(process.cwd(), "README", "Mas Ejemplos.md"); + if (!fs.existsSync(readPath)) { + await channel.send("README/Mas Ejemplos.md no encontrado en el repo."); + return; + } + const raw = fs.readFileSync(readPath, "utf8"); + + // Extract two sections: "Flujo rápido" and "Items: creación" and the mobs section + // We'll be generous and send large chunks; the README already contains the examples. + const header = "# Guía rápida para el staff"; + const basicIndex = raw.indexOf( + "## Flujo rápido: Crear un ítem con receta" + ); + const itemsIndex = raw.indexOf("## Items: creación, edición y revisión"); + const mobsIndex = raw.indexOf("## Mobs: enemigos y NPCs"); + + // Fallback: send the whole file (chunked) if parsing fails + if (basicIndex === -1 || itemsIndex === -1) { + const chunks = chunkText(raw); + for (const c of chunks) await channel.send(c); + if (!showAdvanced) return; + // advanced is basically the rest of the README; already sent + return; + } + + const basicSection = raw.slice(basicIndex, itemsIndex); + const itemsSection = raw.slice( + itemsIndex, + mobsIndex === -1 ? raw.length : mobsIndex + ); + const mobsSection = + mobsIndex === -1 ? "" : raw.slice(mobsIndex, raw.length); + + // Send basic & items + for (const chunk of chunkText(basicSection)) await channel.send(chunk); + for (const chunk of chunkText(itemsSection)) await channel.send(chunk); + + if (showAdvanced) { + for (const chunk of chunkText(mobsSection)) await channel.send(chunk); + // Also send rest of file + const restIndex = raw.indexOf("\n---\n", mobsIndex); + if (restIndex !== -1) { + const rest = raw.slice(restIndex); + for (const chunk of chunkText(rest)) await channel.send(chunk); + } + } else { + await channel.send( + "Usa `!setup advanced` para publicar la sección avanzada (mobs, crafteos avanzados y workflows)." + ); + } + } catch (e) { + logger.error({ e }, "setup command failed"); + await channel.send("❌ Error al publicar ejemplos. Revisa logs."); + } + }, +}; diff --git a/src/game/economy/service.ts b/src/game/economy/service.ts index 98f5a91..d0e051d 100644 --- a/src/game/economy/service.ts +++ b/src/game/economy/service.ts @@ -72,6 +72,9 @@ export async function getInventoryEntryByItemId( }); if (existing) return existing; if (!opts?.createIfMissing) return null; + // Asegurar que User y Guild existan antes de crear inventoryEntry para evitar + // errores de constraint por foreign keys inexistentes. + await ensureUserAndGuildExist(userId, guildId); return prisma.inventoryEntry.create({ data: { userId, guildId, itemId, quantity: 0 }, }); diff --git a/src/game/minigames/service.ts b/src/game/minigames/service.ts index 97f501c..cee161b 100644 --- a/src/game/minigames/service.ts +++ b/src/game/minigames/service.ts @@ -10,6 +10,7 @@ import { findItemByKey, getInventoryEntry, } from "../economy/service"; +import { findMobDef } from "../mobs/mobData"; import { getEffectiveStats, adjustHP, @@ -221,6 +222,8 @@ async function applyRewards( // Detectar efecto FATIGUE activo para penalizar SOLO monedas. let fatigueMagnitude: number | undefined; + // prepare a container for merged modifiers so it's available later in the result + let mergedRewardModifiers: RunResult["rewardModifiers"] | undefined; try { const effects = await getActiveStatusEffects(userId, guildId); const fatigue = effects.find((e) => e.type === "FATIGUE"); @@ -460,6 +463,110 @@ export async function runMinigame( ); const mobsSpawned = await sampleMobInstances(mobs, level); + // container visible for the whole runMinigame scope so we can attach mob-derived modifiers + let mergedRewardModifiers: RunResult["rewardModifiers"] | undefined = + undefined; + + // --- Aplicar rewardMods de los mobs (coinMultiplier y extraDropChance) + // Nota: applyRewards ya aplicó monedas/items base. Aquí solo aplicamos + // incrementos por rewardMods de mobs: aumentos positivos en monedas y + // posibles drops extra (aquí simplificado como +1 moneda por evento). + try { + // calcular total de monedas entregadas hasta ahora + const totalCoins = delivered + .filter((r) => r.type === "coins") + .reduce((s, r) => s + (r.amount || 0), 0); + + // multiplicador compuesto por mobs (producto de coinMultiplier) + const mobCoinMultiplier = mobsSpawned.reduce((acc, m) => { + const cm = (m && (m.rewardMods as any)?.coinMultiplier) ?? 1; + return acc * (typeof cm === "number" ? cm : 1); + }, 1); + + // Si el multiplicador es mayor a 1, añadimos la diferencia en monedas + if (mobCoinMultiplier > 1 && totalCoins > 0) { + const newTotal = Math.max(0, Math.floor(totalCoins * mobCoinMultiplier)); + const delta = newTotal - totalCoins; + if (delta !== 0) { + await adjustCoins(userId, guildId, delta); + delivered.push({ type: "coins", amount: delta }); + } + } + + // extraDropChance: por cada mob, tirada para dar un drop. + // Si la definición del mob incluye `drops` (tabla de items), intentamos elegir uno. + // Si no hay drops configurados o la selección falla, otorgamos 1 coin como fallback. + let extraDropsGiven = 0; + for (const m of mobsSpawned) { + const chance = (m && (m.rewardMods as any)?.extraDropChance) ?? 0; + if (typeof chance === "number" && chance > 0 && Math.random() < chance) { + try { + // Intentar usar la tabla `drops` si existe en la definición original (buscada via findMobDef) + const def = (m && findMobDef(m.key)) as any; + const drops = def?.drops ?? def?.rewards ?? null; + let granted = false; + if (drops) { + // Formato A (ponderado): [{ itemKey, qty?, weight? }, ...] + if (Array.isArray(drops) && drops.length > 0) { + const total = drops.reduce( + (s: number, d: any) => s + (Number(d.weight) || 1), + 0 + ); + let r = Math.random() * total; + for (const d of drops) { + const w = Number(d.weight) || 1; + r -= w; + if (r <= 0) { + const sel = d.itemKey; + const qty = Number(d.qty) || 1; + await addItemByKey(userId, guildId, sel, qty); + delivered.push({ type: "item", itemKey: sel, qty }); + granted = true; + break; + } + } + } else if (typeof drops === "object") { + // Formato B (map simple): { itemKey: qty } + const keys = Object.keys(drops || {}); + if (keys.length > 0) { + const sel = keys[Math.floor(Math.random() * keys.length)]; + const qty = Number((drops as any)[sel]) || 1; + await addItemByKey(userId, guildId, sel, qty); + delivered.push({ type: "item", itemKey: sel, qty }); + granted = true; + } + } + } + if (!granted) { + // fallback: coin + await adjustCoins(userId, guildId, 1); + delivered.push({ type: "coins", amount: 1 }); + } + extraDropsGiven++; + } catch (e) { + // en error, conceder fallback monetario pero no interrumpir + try { + await adjustCoins(userId, guildId, 1); + delivered.push({ type: "coins", amount: 1 }); + extraDropsGiven++; + } catch {} + } + } + } + + // Construir un objeto mergedRewardModifiers en lugar de mutar rewardModifiers + mergedRewardModifiers = { + ...((rewardModifiers as any) || {}), + mobCoinMultiplier, + extraDropsGiven: + ((rewardModifiers as any)?.extraDropsGiven || 0) + extraDropsGiven, + } as any; + } catch (err) { + // No queremos que fallos menores de rewardMods rompan la ejecución + // eslint-disable-next-line no-console + console.warn("applyMobRewardMods: failed:", (err as any)?.message ?? err); + } + // Reducir durabilidad de herramienta si se usó let toolInfo: RunResult["tool"] | undefined; if (reqRes.toolKeyUsed) { @@ -845,13 +952,117 @@ export async function runMinigame( } } + // --- Aplicar rewardMods provenientes de mobs derrotados (post-combate) + try { + if (combatSummary) { + const defeated = (combatSummary.mobs || []).filter((m) => m.defeated); + if (defeated.length > 0) { + // multiplicador compuesto solo por mobs derrotados + const defeatedMultiplier = defeated.reduce((acc, dm) => { + try { + const def = findMobDef(dm.mobKey) as any; + const cm = + (def && def.rewardMods && def.rewardMods.coinMultiplier) ?? 1; + return acc * (typeof cm === "number" ? cm : 1); + } catch { + return acc; + } + }, 1); + + const totalCoinsBefore = delivered + .filter((r) => r.type === "coins") + .reduce((s, r) => s + (r.amount || 0), 0); + + if (defeatedMultiplier > 1 && totalCoinsBefore > 0) { + const newTotal = Math.max( + 0, + Math.floor(totalCoinsBefore * defeatedMultiplier) + ); + const delta = newTotal - totalCoinsBefore; + if (delta !== 0) { + await adjustCoins(userId, guildId, delta); + delivered.push({ type: "coins", amount: delta }); + } + } + + // extra drops por cada mob derrotado + let extraDropsFromCombat = 0; + for (const dm of defeated) { + try { + const def = findMobDef(dm.mobKey) as any; + const chance = + (def && def.rewardMods && def.rewardMods.extraDropChance) ?? 0; + if ( + typeof chance === "number" && + chance > 0 && + Math.random() < chance + ) { + // intentar dropear item similar al flujo de minigame + const drops = def?.drops ?? def?.rewards ?? null; + let granted = false; + if (drops) { + if (Array.isArray(drops) && drops.length > 0) { + const total = drops.reduce( + (s: number, d: any) => s + (Number(d.weight) || 1), + 0 + ); + let r = Math.random() * total; + for (const d of drops) { + const w = Number(d.weight) || 1; + r -= w; + if (r <= 0) { + const sel = d.itemKey; + const qty = Number(d.qty) || 1; + await addItemByKey(userId, guildId, sel, qty); + delivered.push({ type: "item", itemKey: sel, qty }); + granted = true; + break; + } + } + } else if (typeof drops === "object") { + const keys = Object.keys(drops || {}); + if (keys.length > 0) { + const sel = keys[Math.floor(Math.random() * keys.length)]; + const qty = Number((drops as any)[sel]) || 1; + await addItemByKey(userId, guildId, sel, qty); + delivered.push({ type: "item", itemKey: sel, qty }); + granted = true; + } + } + } + if (!granted) { + await adjustCoins(userId, guildId, 1); + delivered.push({ type: "coins", amount: 1 }); + } + extraDropsFromCombat++; + } + } catch {} + } + + // Fusionar con mergedRewardModifiers + mergedRewardModifiers = { + ...((mergedRewardModifiers as any) || (rewardModifiers as any) || {}), + defeatedMobCoinMultiplier: + ((mergedRewardModifiers as any)?.defeatedMobCoinMultiplier || 1) * + (defeatedMultiplier || 1), + extraDropsFromCombat: + ((mergedRewardModifiers as any)?.extraDropsFromCombat || 0) + + extraDropsFromCombat, + } as any; + } + } + } catch (e) { + // no bloquear ejecución por fallos en recompensas secundarias + } + const resultJson: Prisma.InputJsonValue = { rewards: delivered, mobs: mobsSpawned.map((m) => m?.key ?? "unknown"), tool: toolInfo, weaponTool: weaponToolInfo, combat: combatSummary, - rewardModifiers, + rewardModifiers: + mergedRewardModifiers ?? (rewardModifiers as any) ?? undefined, notes: "auto", } as unknown as Prisma.InputJsonValue; @@ -900,7 +1111,7 @@ export async function runMinigame( tool: toolInfo, weaponTool: weaponToolInfo, combat: combatSummary, - rewardModifiers, + rewardModifiers: mergedRewardModifiers, }; } diff --git a/src/game/minigames/testHelpers.ts b/src/game/minigames/testHelpers.ts new file mode 100644 index 0000000..ba507d7 --- /dev/null +++ b/src/game/minigames/testHelpers.ts @@ -0,0 +1,32 @@ +import { findMobDef } from "../mobs/mobData"; + +export function pickDropFromDef( + def: any +): { itemKey: string; qty: number } | null { + if (!def) return null; + const drops = def.drops ?? def.rewards ?? null; + if (!drops) return null; + if (Array.isArray(drops) && drops.length > 0) { + const total = drops.reduce( + (s: number, d: any) => s + (Number(d.weight) || 1), + 0 + ); + let r = Math.random() * total; + for (const d of drops) { + const w = Number(d.weight) || 1; + r -= w; + if (r <= 0) return { itemKey: d.itemKey, qty: Number(d.qty) || 1 }; + } + return { + itemKey: drops[drops.length - 1].itemKey, + qty: Number(drops[drops.length - 1].qty) || 1, + }; + } + if (typeof drops === "object") { + const keys = Object.keys(drops || {}); + if (keys.length === 0) return null; + const sel = keys[Math.floor(Math.random() * keys.length)]; + return { itemKey: sel, qty: Number(drops[sel]) || 1 }; + } + return null; +} diff --git a/src/game/mobs/admin.ts b/src/game/mobs/admin.ts index e03ec89..06f7b22 100644 --- a/src/game/mobs/admin.ts +++ b/src/game/mobs/admin.ts @@ -1,40 +1,11 @@ import { prisma } from "../../core/database/prisma"; import { z } from "zod"; -import { BaseMobDefinition, MOB_DEFINITIONS, findMobDef } from "./mobData"; - -const BaseMobDefinitionSchema = z.object({ - key: z.string(), - name: z.string(), - tier: z.number().int().nonnegative(), - base: z.object({ - hp: z.number(), - attack: z.number(), - defense: z.number().optional(), - }), - scaling: z - .object({ - hpPerLevel: z.number().optional(), - attackPerLevel: z.number().optional(), - defensePerLevel: z.number().optional(), - hpMultiplierPerTier: z.number().optional(), - }) - .optional(), - tags: z.array(z.string()).optional(), - rewardMods: z - .object({ - coinMultiplier: z.number().optional(), - extraDropChance: z.number().optional(), - }) - .optional(), - behavior: z - .object({ - maxRounds: z.number().optional(), - aggressive: z.boolean().optional(), - critChance: z.number().optional(), - critMultiplier: z.number().optional(), - }) - .optional(), -}); +import { + BaseMobDefinition, + MOB_DEFINITIONS, + findMobDef, + BaseMobDefinitionSchema, +} from "./mobData"; type MobInput = z.infer; diff --git a/src/game/mobs/mobData.ts b/src/game/mobs/mobData.ts index 77cf1df..4aef122 100644 --- a/src/game/mobs/mobData.ts +++ b/src/game/mobs/mobData.ts @@ -29,6 +29,17 @@ export interface BaseMobDefinition { }; } +// Nota sobre 'drops': +// El motor soporta, además de la definición básica del mob, un campo opcional `drops` +// que puede vivir en la definición del mob (o en la fila DB `Mob.drops`). Hay dos formatos +// soportados por conveniencia: +// 1) Mapa simple (object): { "item.key": qty, "other.key": qty } +// - Selección aleatoria entre las keys y entrega qty del item seleccionado. +// 2) Array ponderado: [{ itemKey: string, qty?: number, weight?: number }, ...] +// - Se realiza una tirada ponderada usando `weight` (por defecto 1) y se entrega `qty`. +// Si no hay drops configurados o la selección falla, la lógica actual aplica un fallback que +// otorga 1 moneda. + // Ejemplos iniciales - se pueden ir expandiendo export const MOB_DEFINITIONS: BaseMobDefinition[] = [ { @@ -114,7 +125,7 @@ export function listMobKeys(): string[] { import { prisma } from "../../core/database/prisma"; import { z } from "zod"; -const BaseMobDefinitionSchema = z.object({ +export const BaseMobDefinitionSchema = z.object({ key: z.string(), name: z.string(), tier: z.number().int().nonnegative(), diff --git a/test/mob.test.ts b/test/mob.test.ts new file mode 100644 index 0000000..6f445a4 --- /dev/null +++ b/test/mob.test.ts @@ -0,0 +1,46 @@ +import assert from "assert"; +import { + computeMobStats, + getMobInstance, + MOB_DEFINITIONS, +} from "../src/game/mobs/mobData"; +import { createOrUpdateMob } from "../src/game/mobs/admin"; + +async function main() { + console.log("Starting formal mob tests..."); + + // Test computeMobStats deterministic + const def = MOB_DEFINITIONS[0]; + const s1 = computeMobStats(def, 1); + const s2 = computeMobStats(def, 2); + assert(s2.hp >= s1.hp, "HP should increase or stay with level"); + console.log("computeMobStats OK"); + + // Test getMobInstance + const inst = getMobInstance(def.key, 3); + assert(inst !== null, "getMobInstance should return an instance"); + assert( + typeof inst!.scaled.hp === "number", + "instance scaled.hp should be a number" + ); + console.log("getMobInstance OK"); + + // Test createOrUpdateMob in no-db mode (should not throw) + try { + const r = await createOrUpdateMob({ ...def, key: "test.unit.mob" } as any); + assert(r && r.def, "createOrUpdateMob must return def"); + console.log("createOrUpdateMob (no-db) OK"); + } catch (e) { + console.warn( + "createOrUpdateMob test skipped (DB needed):", + (e as any)?.message ?? e + ); + } + + console.log("All formal mob tests passed"); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/test/unit/rewardMods.test.ts b/test/unit/rewardMods.test.ts new file mode 100644 index 0000000..3e66c2a --- /dev/null +++ b/test/unit/rewardMods.test.ts @@ -0,0 +1,59 @@ +import { test } from "uvu"; +import * as assert from "uvu/assert"; +import { pickDropFromDef } from "../../src/game/minigames/testHelpers"; + +// deterministic randomness helper +function seedRandom(seed: number) { + let s = seed % 2147483647; + if (s <= 0) s += 2147483646; + return () => (s = (s * 16807) % 2147483647) / 2147483647; +} + +// Patch Math.random for deterministic tests +const realRandom = Math.random; + +test.before(() => { + (Math as any).random = seedRandom(42) as any; +}); + +test.after(() => { + (Math as any).random = realRandom; +}); + +test("pickDropFromDef chooses weighted item", () => { + const def = { + drops: [ + { itemKey: "ore.iron", qty: 1, weight: 8 }, + { itemKey: "ore.gold", qty: 1, weight: 2 }, + ], + } as any; + const picks = new Set(); + for (let i = 0; i < 10; i++) { + const p = pickDropFromDef(def); + assert.ok(p && (p.itemKey === "ore.iron" || p.itemKey === "ore.gold")); + picks.add(p!.itemKey); + } + // with seeded RNG both options should appear + assert.ok(picks.size >= 1); +}); + +test("pickDropFromDef chooses from map", () => { + const def = { drops: { "ore.iron": 1, "ore.gold": 2 } } as any; + const p = pickDropFromDef(def); + assert.ok(p && (p.itemKey === "ore.iron" || p.itemKey === "ore.gold")); +}); + +// coinMultiplier behavior is multiplicative in current design; test small scenario +test("coin multiplier aggregation (product)", () => { + const mobs = [ + { rewardMods: { coinMultiplier: 1.1 } }, + { rewardMods: { coinMultiplier: 1.2 } }, + ]; + const product = mobs.reduce( + (acc, m) => acc * ((m.rewardMods?.coinMultiplier as number) || 1), + 1 + ); + assert.equal(Math.round(product * 100) / 100, Math.round(1.32 * 100) / 100); +}); + +test.run(); diff --git a/test/unit/rewardMods.unit.ts b/test/unit/rewardMods.unit.ts new file mode 100644 index 0000000..f6ec3fe --- /dev/null +++ b/test/unit/rewardMods.unit.ts @@ -0,0 +1,57 @@ +import * as assert from "assert"; +import { pickDropFromDef } from "../../src/game/minigames/testHelpers"; + +// deterministic RNG +function seedRandom(seed: number) { + let s = seed % 2147483647; + if (s <= 0) s += 2147483646; + return () => (s = (s * 16807) % 2147483647) / 2147483647; +} + +const rand = seedRandom(42); +const realRandom = Math.random; +(Math as any).random = rand; + +try { + // weighted + const def1 = { + drops: [ + { itemKey: "ore.iron", qty: 1, weight: 8 }, + { itemKey: "ore.gold", qty: 1, weight: 2 }, + ], + } as any; + const picks = new Set(); + for (let i = 0; i < 20; i++) { + const p = pickDropFromDef(def1); + if (!p) throw new Error("expected pick"); + picks.add(p.itemKey); + } + assert.ok(picks.size >= 1, "expected at least 1 picked key"); + + // map + const def2 = { drops: { "ore.iron": 1, "ore.gold": 2 } } as any; + const p2 = pickDropFromDef(def2); + assert.ok(p2 && (p2.itemKey === "ore.iron" || p2.itemKey === "ore.gold")); + + // coin multiplier product + const mobs = [ + { rewardMods: { coinMultiplier: 1.1 } }, + { rewardMods: { coinMultiplier: 1.2 } }, + ]; + const product = mobs.reduce( + (acc, m) => acc * ((m.rewardMods?.coinMultiplier as number) || 1), + 1 + ); + assert.strictEqual( + Math.round(product * 100) / 100, + Math.round(1.32 * 100) / 100 + ); + + console.log("All unit tests passed"); + (Math as any).random = realRandom; + process.exit(0); +} catch (e) { + (Math as any).random = realRandom; + console.error(e); + process.exit(1); +}