feat: Add scripts for mob dependency management and server setup

- Implemented `findMobDependencies.ts` to identify foreign key constraints referencing the Mob table and log dependent rows.
- Created `fullServerSetup.ts` for idempotent server setup, including economy items, item recipes, game areas, mobs, and optional demo mob attacks.
- Developed `removeInvalidMobsWithDeps.ts` to delete invalid mobs and their dependencies, backing up affected scheduled mob attacks.
- Added unit tests in `testMobUnit.ts` and `mob.test.ts` for mob functionality, including stats computation and instance retrieval.
- Introduced reward modification tests in `testRewardMods.ts` and `rewardMods.unit.ts` to validate drop selection and coin multiplier behavior.
- Enhanced command handling for mob deletion in `mobDelete.ts` and setup examples in `setup.ts`, ensuring proper permissions and feedback.
- Created utility functions in `testHelpers.ts` for deterministic drop selection from mob definitions.
This commit is contained in:
Shni
2025-10-14 14:58:38 -05:00
parent f36fa24e46
commit 852b1d02a2
24 changed files with 2158 additions and 177 deletions

56
README/CLEANUP.md Normal file
View File

@@ -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.

111
README/CREATE_MOB.md Normal file
View File

@@ -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`.

131
invalid_mobs_backup.json Normal file
View File

@@ -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"
}
}
]

9
package-lock.json generated
View File

@@ -21,7 +21,8 @@
"node-appwrite": "19.1.0", "node-appwrite": "19.1.0",
"pino": "9.13.0", "pino": "9.13.0",
"prisma": "6.16.2", "prisma": "6.16.2",
"redis": "5.8.2" "redis": "5.8.2",
"zod": "4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "^3.1.5", "@types/ejs": "^3.1.5",
@@ -3463,9 +3464,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.1.11", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View File

@@ -19,6 +19,8 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"seed:minigames": "npx tsx src/game/minigames/seed.ts", "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" "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": [], "keywords": [],
@@ -37,9 +39,9 @@
"newrelic": "13.4.0", "newrelic": "13.4.0",
"node-appwrite": "19.1.0", "node-appwrite": "19.1.0",
"pino": "9.13.0", "pino": "9.13.0",
"zod": "4.25.1",
"prisma": "6.16.2", "prisma": "6.16.2",
"redis": "5.8.2" "redis": "5.8.2",
"zod": "4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "^3.1.5", "@types/ejs": "^3.1.5",

View File

@@ -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
}
]

View File

@@ -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);
});

View File

@@ -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);
});

393
scripts/fullServerSetup.ts Normal file
View File

@@ -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<Prisma.EconomyItemUncheckedCreateInput, "key" | "guildId">
) {
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<Prisma.GameAreaUncheckedCreateInput, "key" | "guildId">
) {
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<Prisma.MobUncheckedCreateInput, "key" | "guildId">
) {
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);
});
}

View File

@@ -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);
});

54
scripts/testMobUnit.ts Normal file
View File

@@ -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);
});

66
scripts/testRewardMods.ts Normal file
View File

@@ -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);
});

View File

@@ -1,6 +1,5 @@
import type { CommandMessage } from "../../../core/types/commands"; import type { CommandMessage } from "../../../core/types/commands";
import type Amayo from "../../../core/client"; import type Amayo from "../../../core/client";
import { prisma } from "../../../core/database/prisma";
import { ComponentType, ButtonStyle } from "discord-api-types/v10"; import { ComponentType, ButtonStyle } from "discord-api-types/v10";
import type { MessageComponentInteraction, TextBasedChannel } from "discord.js"; import type { MessageComponentInteraction, TextBasedChannel } from "discord.js";

View File

@@ -1,9 +1,19 @@
import type { CommandMessage } from '../../../core/types/commands'; import type { CommandMessage } from "../../../core/types/commands";
import type Amayo from '../../../core/client'; import type Amayo from "../../../core/client";
import { hasManageGuildOrStaff } from '../../../core/lib/permissions'; import { hasManageGuildOrStaff } from "../../../core/lib/permissions";
import { prisma } from '../../../core/database/prisma'; import { prisma } from "../../../core/database/prisma";
import { Message, MessageComponentInteraction, MessageFlags, ButtonInteraction, TextBasedChannel } from 'discord.js'; import {
import { ComponentType, TextInputStyle, ButtonStyle } from 'discord-api-types/v10'; Message,
MessageComponentInteraction,
MessageFlags,
ButtonInteraction,
TextBasedChannel,
} from "discord.js";
import {
ComponentType,
TextInputStyle,
ButtonStyle,
} from "discord-api-types/v10";
interface AreaState { interface AreaState {
key: string; key: string;
@@ -14,43 +24,43 @@ interface AreaState {
} }
function buildAreaDisplay(state: AreaState, editing: boolean = false) { function buildAreaDisplay(state: AreaState, editing: boolean = false) {
const title = editing ? 'Editando Área' : 'Creando Área'; const title = editing ? "Editando Área" : "Creando Área";
const statusText = [ const statusText = [
'**📋 Estado Actual:**', "**📋 Estado Actual:**",
`**Nombre:** ${state.name || '❌ No configurado'}`, `**Nombre:** ${state.name || "❌ No configurado"}`,
`**Tipo:** ${state.type || '❌ No configurado'}`, `**Tipo:** ${state.type || "❌ No configurado"}`,
`**Config:** ${Object.keys(state.config || {}).length} campos`, `**Config:** ${Object.keys(state.config || {}).length} campos`,
`**Metadata:** ${Object.keys(state.metadata || {}).length} campos` `**Metadata:** ${Object.keys(state.metadata || {}).length} campos`,
].join('\n'); ].join("\n");
const instructionsText = [ const instructionsText = [
'**🎮 Instrucciones:**', "**🎮 Instrucciones:**",
'• **Base**: Configura nombre y tipo', "• **Base**: Configura nombre y tipo",
'• **Config (JSON)**: Configuración técnica', "• **Config (JSON)**: Configuración técnica",
'• **Meta (JSON)**: Metadatos adicionales', "• **Meta (JSON)**: Metadatos adicionales",
'• **Guardar**: Confirma los cambios', "• **Guardar**: Confirma los cambios",
'• **Cancelar**: Descarta los cambios' "• **Cancelar**: Descarta los cambios",
].join('\n'); ].join("\n");
return { return {
type: 17, type: 17,
accent_color: 0x00FF00, accent_color: 0x00ff00,
components: [ components: [
{ {
type: 10, type: 10,
content: `# 🗺️ ${title}: \`${state.key}\`` content: `# 🗺️ ${title}: \`${state.key}\``,
}, },
{ type: 14, divider: true }, { type: 14, divider: true },
{ {
type: 10, type: 10,
content: statusText content: statusText,
}, },
{ type: 14, divider: true }, { type: 14, divider: true },
{ {
type: 10, type: 10,
content: instructionsText content: instructionsText,
} },
] ],
}; };
} }
@@ -59,38 +69,73 @@ const buildEditorComponents = (state: AreaState, editing: boolean = false) => [
{ {
type: 1, type: 1,
components: [ 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,
{ type: 2, style: ButtonStyle.Secondary, label: 'Meta (JSON)', custom_id: 'ga_meta' }, style: ButtonStyle.Primary,
{ type: 2, style: ButtonStyle.Success, label: 'Guardar', custom_id: 'ga_save' }, label: "Base",
{ type: 2, style: ButtonStyle.Danger, label: 'Cancelar', custom_id: 'ga_cancel' }, 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 = { export const command: CommandMessage = {
name: 'area-crear', name: "area-crear",
type: 'message', type: "message",
aliases: ['crear-area','areacreate'], aliases: ["crear-area", "areacreate"],
cooldown: 10, cooldown: 10,
description: 'Crea una GameArea (mina/laguna/arena/farm) para este servidor con editor.', description:
usage: 'area-crear <key-única>', "Crea una GameArea (mina/laguna/arena/farm) para este servidor con editor.",
usage: "area-crear <key-única>",
run: async (message, args, _client: Amayo) => { run: async (message, args, _client: Amayo) => {
const channel = message.channel as TextBasedChannel & { send: Function }; 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) { if (!allowed) {
await (channel.send as any)({ await (channel.send as any)({
content: null, content: null,
flags: 32768, flags: 32768,
components: [{ components: [
{
type: 17, type: 17,
accent_color: 0xFF0000, accent_color: 0xff0000,
components: [{ components: [
{
type: 10, type: 10,
content: '❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.' content:
}] "❌ **Error de Permisos**\n└ No tienes permisos de ManageGuild ni rol de staff.",
}], },
reply: { messageReference: message.id } ],
},
],
reply: { messageReference: message.id },
}); });
return; return;
} }
@@ -100,15 +145,20 @@ export const command: CommandMessage = {
await (channel.send as any)({ await (channel.send as any)({
content: null, content: null,
flags: 32768, flags: 32768,
components: [{ components: [
{
type: 17, type: 17,
accent_color: 0xFFA500, accent_color: 0xffa500,
components: [{ components: [
{
type: 10, type: 10,
content: '⚠️ **Uso Incorrecto**\n└ Uso: `!area-crear <key-única>`' content:
}] "⚠️ **Uso Incorrecto**\n└ Uso: `!area-crear <key-única>`",
}], },
reply: { messageReference: message.id } ],
},
],
reply: { messageReference: message.id },
}); });
return; return;
} }
@@ -119,15 +169,20 @@ export const command: CommandMessage = {
await (channel.send as any)({ await (channel.send as any)({
content: null, content: null,
flags: 32768, flags: 32768,
components: [{ components: [
{
type: 17, type: 17,
accent_color: 0xFF0000, accent_color: 0xff0000,
components: [{ components: [
{
type: 10, type: 10,
content: '❌ **Área Ya Existe**\n└ Ya existe un área con esa key en este servidor.' content:
}] "❌ **Área Ya Existe**\n└ Ya existe un área con esa key en este servidor.",
}], },
reply: { messageReference: message.id } ],
},
],
reply: { messageReference: message.id },
}); });
return; return;
} }
@@ -138,133 +193,271 @@ export const command: CommandMessage = {
content: null, content: null,
flags: 32768, flags: 32768,
components: buildEditorComponents(state, false), 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 }); const collector = editorMsg.createMessageComponentCollector({
collector.on('collect', async (i: MessageComponentInteraction) => { time: 30 * 60_000,
filter: (i) => i.user.id === message.author.id,
});
collector.on("collect", async (i: MessageComponentInteraction) => {
try { try {
if (!i.isButton()) return; if (!i.isButton()) return;
switch (i.customId) { switch (i.customId) {
case 'ga_cancel': case "ga_cancel":
await i.deferUpdate(); await i.deferUpdate();
await editorMsg.edit({ await editorMsg.edit({
content: null, content: null,
flags: 32768, flags: 32768,
components: [{ components: [
{
type: 17, type: 17,
accent_color: 0xFF0000, accent_color: 0xff0000,
components: [{ components: [
{
type: 10, type: 10,
content: '**❌ Editor de Área cancelado.**' content: "**❌ Editor de Área cancelado.**",
}] },
}] ],
},
],
}); });
collector.stop('cancel'); collector.stop("cancel");
return; return;
case 'ga_base': case "ga_base":
await showBaseModal(i as ButtonInteraction, state, editorMsg, false); await showBaseModal(
i as ButtonInteraction,
state,
editorMsg,
false
);
return; return;
case 'ga_config': case "ga_config":
await showJsonModal(i as ButtonInteraction, state, 'config', 'Config del Área', editorMsg, false); await showJsonModal(
i as ButtonInteraction,
state,
"config",
"Config del Área",
editorMsg,
false
);
return; return;
case 'ga_meta': case "ga_meta":
await showJsonModal(i as ButtonInteraction, state, 'metadata', 'Meta del Área', editorMsg, false); await showJsonModal(
i as ButtonInteraction,
state,
"metadata",
"Meta del Área",
editorMsg,
false
);
return; return;
case 'ga_save': case "ga_save":
if (!state.name || !state.type) { await i.reply({ content: '❌ Completa Base (nombre/tipo).', flags: MessageFlags.Ephemeral }); return; } if (!state.name || !state.type) {
await prisma.gameArea.create({ data: { guildId, key: state.key, name: state.name!, type: state.type!, config: state.config ?? {}, metadata: state.metadata ?? {} } }); await i.reply({
await i.reply({ content: '✅ Área guardada.', flags: MessageFlags.Ephemeral }); 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({ await editorMsg.edit({
content: null, content: null,
flags: 32768, flags: 32768,
components: [{ components: [
{
type: 17, type: 17,
accent_color: 0x00FF00, accent_color: 0x00ff00,
components: [{ components: [
{
type: 10, type: 10,
content: `**✅ Área \`${state.key}\` creada exitosamente.**` content: `**✅ Área \`${state.key}\` creada exitosamente.**`,
}] },
}] ],
},
],
}); });
collector.stop('saved'); collector.stop("saved");
return; return;
} }
} catch (e) { } 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)=> { collector.on("end", async (_c, r) => {
if (r==='time') { if (r === "time") {
try { try {
await editorMsg.edit({ await editorMsg.edit({
content: null, content: null,
flags: 32768, flags: 32768,
components: [{ components: [
{
type: 17, type: 17,
accent_color: 0xFFA500, accent_color: 0xffa500,
components: [{ components: [
{
type: 10, type: 10,
content: '**⏰ Editor expirado.**' content: "**⏰ Editor expirado.**",
}] },
}] ],
},
],
}); });
} catch {} } catch {}
} }
}); });
} },
}; };
async function showBaseModal(i: ButtonInteraction, state: AreaState, editorMsg: Message, editing: boolean) { async function showBaseModal(
const modal = { title: 'Base del Área', customId: 'ga_base_modal', components: [ i: ButtonInteraction,
{ type: ComponentType.Label, label: 'Nombre', component: { type: ComponentType.TextInput, customId: 'name', style: TextInputStyle.Short, required: true, value: state.name ?? '' } }, state: AreaState,
{ type: ComponentType.Label, label: 'Tipo (MINE/LAGOON/FIGHT/FARM)', component: { type: ComponentType.TextInput, customId: 'type', style: TextInputStyle.Short, required: true, value: state.type ?? '' } }, editorMsg: Message,
] } as const; 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); await i.showModal(modal);
try { try {
const sub = await i.awaitModalSubmit({ time: 300_000 }); const sub = await i.awaitModalSubmit({ time: 300_000 });
state.name = sub.components.getTextInputValue('name').trim(); state.name = sub.components.getTextInputValue("name").trim();
state.type = sub.components.getTextInputValue('type').trim().toUpperCase(); state.type = sub.components.getTextInputValue("type").trim().toUpperCase();
await sub.reply({ content: '✅ Base actualizada.', flags: MessageFlags.Ephemeral }); 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 // Actualizar display
await editorMsg.edit({ await editorMsg.edit({
content: null, content: null,
flags: 32768, flags: 32768,
components: buildEditorComponents(state, editing) components: buildEditorComponents(state, editing),
}); });
} catch {} } 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 current = JSON.stringify(state[field] ?? {});
const modal = { title, customId: `ga_json_${field}`, components: [ const modal = {
{ type: ComponentType.Label, label: 'JSON', component: { type: ComponentType.TextInput, customId: 'json', style: TextInputStyle.Paragraph, required: false, value: current.slice(0,4000) } }, title,
] } as const; 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); await i.showModal(modal);
try { try {
const sub = await i.awaitModalSubmit({ time: 300_000 }); const sub = await i.awaitModalSubmit({ time: 300_000 });
const raw = sub.components.getTextInputValue('json'); const raw = sub.components.getTextInputValue("json");
if (raw) { if (raw) {
try { try {
state[field] = JSON.parse(raw); state[field] = JSON.parse(raw);
await sub.reply({ content: '✅ Guardado.', flags: MessageFlags.Ephemeral }); await sub.reply({
content: "✅ Guardado.",
flags: MessageFlags.Ephemeral,
});
} catch { } catch {
await sub.reply({ content: '❌ JSON inválido.', flags: MessageFlags.Ephemeral }); await sub.reply({
content: "❌ JSON inválido.",
flags: MessageFlags.Ephemeral,
});
return; return;
} }
} else { } else {
state[field] = {}; state[field] = {};
await sub.reply({ content: ' Limpio.', flags: MessageFlags.Ephemeral }); await sub.reply({ content: " Limpio.", flags: MessageFlags.Ephemeral });
} }
// Actualizar display // Actualizar display
await editorMsg.edit({ await editorMsg.edit({
content: null, content: null,
flags: 32768, flags: 32768,
components: buildEditorComponents(state, editing) components: buildEditorComponents(state, editing),
}); });
} catch {} } catch {}
} }

View File

@@ -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,
});
}
},
};

View File

@@ -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.");
}
},
};

View File

@@ -72,6 +72,9 @@ export async function getInventoryEntryByItemId(
}); });
if (existing) return existing; if (existing) return existing;
if (!opts?.createIfMissing) return null; 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({ return prisma.inventoryEntry.create({
data: { userId, guildId, itemId, quantity: 0 }, data: { userId, guildId, itemId, quantity: 0 },
}); });

View File

@@ -10,6 +10,7 @@ import {
findItemByKey, findItemByKey,
getInventoryEntry, getInventoryEntry,
} from "../economy/service"; } from "../economy/service";
import { findMobDef } from "../mobs/mobData";
import { import {
getEffectiveStats, getEffectiveStats,
adjustHP, adjustHP,
@@ -221,6 +222,8 @@ async function applyRewards(
// Detectar efecto FATIGUE activo para penalizar SOLO monedas. // Detectar efecto FATIGUE activo para penalizar SOLO monedas.
let fatigueMagnitude: number | undefined; let fatigueMagnitude: number | undefined;
// prepare a container for merged modifiers so it's available later in the result
let mergedRewardModifiers: RunResult["rewardModifiers"] | undefined;
try { try {
const effects = await getActiveStatusEffects(userId, guildId); const effects = await getActiveStatusEffects(userId, guildId);
const fatigue = effects.find((e) => e.type === "FATIGUE"); const fatigue = effects.find((e) => e.type === "FATIGUE");
@@ -460,6 +463,110 @@ export async function runMinigame(
); );
const mobsSpawned = await sampleMobInstances(mobs, level); 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ó // Reducir durabilidad de herramienta si se usó
let toolInfo: RunResult["tool"] | undefined; let toolInfo: RunResult["tool"] | undefined;
if (reqRes.toolKeyUsed) { 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 = { const resultJson: Prisma.InputJsonValue = {
rewards: delivered, rewards: delivered,
mobs: mobsSpawned.map((m) => m?.key ?? "unknown"), mobs: mobsSpawned.map((m) => m?.key ?? "unknown"),
tool: toolInfo, tool: toolInfo,
weaponTool: weaponToolInfo, weaponTool: weaponToolInfo,
combat: combatSummary, combat: combatSummary,
rewardModifiers, rewardModifiers:
mergedRewardModifiers ?? (rewardModifiers as any) ?? undefined,
notes: "auto", notes: "auto",
} as unknown as Prisma.InputJsonValue; } as unknown as Prisma.InputJsonValue;
@@ -900,7 +1111,7 @@ export async function runMinigame(
tool: toolInfo, tool: toolInfo,
weaponTool: weaponToolInfo, weaponTool: weaponToolInfo,
combat: combatSummary, combat: combatSummary,
rewardModifiers, rewardModifiers: mergedRewardModifiers,
}; };
} }

View File

@@ -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;
}

View File

@@ -1,40 +1,11 @@
import { prisma } from "../../core/database/prisma"; import { prisma } from "../../core/database/prisma";
import { z } from "zod"; import { z } from "zod";
import { BaseMobDefinition, MOB_DEFINITIONS, findMobDef } from "./mobData"; import {
BaseMobDefinition,
const BaseMobDefinitionSchema = z.object({ MOB_DEFINITIONS,
key: z.string(), findMobDef,
name: z.string(), BaseMobDefinitionSchema,
tier: z.number().int().nonnegative(), } from "./mobData";
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(),
});
type MobInput = z.infer<typeof BaseMobDefinitionSchema>; type MobInput = z.infer<typeof BaseMobDefinitionSchema>;

View File

@@ -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 // Ejemplos iniciales - se pueden ir expandiendo
export const MOB_DEFINITIONS: BaseMobDefinition[] = [ export const MOB_DEFINITIONS: BaseMobDefinition[] = [
{ {
@@ -114,7 +125,7 @@ export function listMobKeys(): string[] {
import { prisma } from "../../core/database/prisma"; import { prisma } from "../../core/database/prisma";
import { z } from "zod"; import { z } from "zod";
const BaseMobDefinitionSchema = z.object({ export const BaseMobDefinitionSchema = z.object({
key: z.string(), key: z.string(),
name: z.string(), name: z.string(),
tier: z.number().int().nonnegative(), tier: z.number().int().nonnegative(),

46
test/mob.test.ts Normal file
View File

@@ -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);
});

View File

@@ -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<string>();
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();

View File

@@ -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<string>();
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);
}