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:
56
README/CLEANUP.md
Normal file
56
README/CLEANUP.md
Normal 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
111
README/CREATE_MOB.md
Normal 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
131
invalid_mobs_backup.json
Normal 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
9
package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
42
scheduled_mob_attack_backup.json
Normal file
42
scheduled_mob_attack_backup.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
54
scripts/cleanInvalidMobs.ts
Normal file
54
scripts/cleanInvalidMobs.ts
Normal 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);
|
||||||
|
});
|
||||||
80
scripts/findMobDependencies.ts
Normal file
80
scripts/findMobDependencies.ts
Normal 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
393
scripts/fullServerSetup.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
72
scripts/removeInvalidMobsWithDeps.ts
Normal file
72
scripts/removeInvalidMobsWithDeps.ts
Normal 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
54
scripts/testMobUnit.ts
Normal 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
66
scripts/testRewardMods.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
178
src/commands/messages/game/mobDelete.ts
Normal file
178
src/commands/messages/game/mobDelete.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
159
src/commands/messages/game/setup.ts
Normal file
159
src/commands/messages/game/setup.ts
Normal 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.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
src/game/minigames/testHelpers.ts
Normal file
32
src/game/minigames/testHelpers.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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
46
test/mob.test.ts
Normal 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);
|
||||||
|
});
|
||||||
59
test/unit/rewardMods.test.ts
Normal file
59
test/unit/rewardMods.test.ts
Normal 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();
|
||||||
57
test/unit/rewardMods.unit.ts
Normal file
57
test/unit/rewardMods.unit.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user