feat: Add scripts for mob dependency management and server setup

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

View File

@@ -0,0 +1,54 @@
import { prisma } from "../src/core/database/prisma";
import { BaseMobDefinitionSchema } from "../src/game/mobs/mobData";
async function run() {
if (!process.env.XATA_DB) {
console.error("XATA_DB not set — aborting");
process.exit(1);
}
console.log("Scanning mobs table for invalid definitions...");
const rows: any[] = await (prisma as any).mob.findMany();
const invalid: any[] = [];
for (const r of rows) {
const cfg =
r.metadata ??
r.stats ??
r.drops ??
r.config ??
r.definition ??
r.data ??
null;
try {
BaseMobDefinitionSchema.parse(cfg as any);
} catch (e) {
invalid.push({ id: r.id, error: (e as any)?.errors ?? e, row: r });
}
}
if (invalid.length === 0) {
console.log("No invalid mob definitions found.");
process.exit(0);
}
console.log(
`Found ${invalid.length} invalid rows. Backing up and deleting...`
);
// backup
console.log("Backup file: invalid_mobs_backup.json");
require("fs").writeFileSync(
"invalid_mobs_backup.json",
JSON.stringify(invalid, null, 2)
);
for (const it of invalid) {
try {
await (prisma as any).mob.delete({ where: { id: it.id } });
console.log("Deleted invalid mob id=", it.id);
} catch (e) {
console.warn("Failed to delete id=", it.id, e);
}
}
console.log("Cleanup complete. Review invalid_mobs_backup.json");
}
run().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,80 @@
import fs from "fs";
import { prisma } from "../src/core/database/prisma";
async function run() {
if (!process.env.XATA_DB) {
console.error("XATA_DB not set — aborting");
process.exit(1);
}
if (!fs.existsSync("invalid_mobs_backup.json")) {
console.error("invalid_mobs_backup.json not found — run cleanup first");
process.exit(1);
}
const bak = JSON.parse(fs.readFileSync("invalid_mobs_backup.json", "utf8"));
const ids: string[] = bak.map((b: any) => b.id).filter(Boolean);
if (ids.length === 0) {
console.log("No ids found in invalid_mobs_backup.json");
return;
}
console.log("Looking for FK constraints that reference the Mob table...");
const fkSql = `
SELECT
tc.constraint_name, kcu.table_name, kcu.column_name, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.constraint_schema = kcu.constraint_schema
JOIN information_schema.constraint_column_usage AS ccu ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND LOWER(ccu.table_name) = 'mob'
`;
const refs: any[] = await (prisma as any).$queryRawUnsafe(fkSql);
if (!refs || refs.length === 0) {
console.log("No FK constraints found referencing mob table.");
return;
}
console.log("Found referencing constraints:");
for (const r of refs) {
console.log(
` - ${r.table_name}.${r.column_name} -> ${r.referenced_table}.${r.referenced_column} (constraint ${r.constraint_name})`
);
}
// For each referencing table/column, search rows that use our ids
for (const r of refs) {
const table = r.table_name;
const column = r.column_name;
console.log(
`\nChecking table ${table} (column ${column}) for dependent rows...`
);
for (const id of ids) {
try {
const cntRes: any[] = await (prisma as any).$queryRawUnsafe(
`SELECT COUNT(*) AS cnt FROM "${table}" WHERE "${column}" = '${id}'`
);
const cnt =
cntRes && cntRes[0]
? Number(cntRes[0].cnt || cntRes[0].count || 0)
: 0;
console.log(` mob id=${id} -> ${cnt} dependent row(s)`);
if (cnt > 0) {
const rows: any[] = await (prisma as any).$queryRawUnsafe(
`SELECT * FROM "${table}" WHERE "${column}" = '${id}' LIMIT 5`
);
console.log(" Sample rows:", rows);
}
} catch (e) {
console.warn(" Failed to query", table, column, e?.message ?? e);
}
}
}
console.log("\nDependency scan complete.");
}
run().catch((e) => {
console.error(e);
process.exit(1);
});

393
scripts/fullServerSetup.ts Normal file
View File

@@ -0,0 +1,393 @@
import { prisma } from "../src/core/database/prisma";
import { Prisma } from "@prisma/client";
/**
* fullServerSetup.ts
*
* Script idempotente para poblar UN servidor con todo lo necesario:
* - Economy items (herramientas, armas, materiales, cofres, pociones)
* - Item recipes (crafteo)
* - Item mutations (encantamientos)
* - Game areas y niveles
* - Mobs con drops
* - Opcional: programar ataques de mobs demo
*
* Uso: provee GUILD_ID como variable de entorno opcional. Si no se provee, usa el id por defecto.
* GUILD_ID=1316592320954630144 npx tsx scripts/fullServerSetup.ts
*/
const DEFAULT_GUILD = process.env.GUILD_ID ?? "1316592320954630144";
async function upsertEconomyItem(
guildId: string | null,
key: string,
data: Omit<Prisma.EconomyItemUncheckedCreateInput, "key" | "guildId">
) {
const existing = await prisma.economyItem.findFirst({
where: { key, guildId },
});
if (existing)
return prisma.economyItem.update({
where: { id: existing.id },
data: { ...data },
});
return prisma.economyItem.create({ data: { ...data, key, guildId } });
}
async function upsertGameArea(
guildId: string | null,
key: string,
data: Omit<Prisma.GameAreaUncheckedCreateInput, "key" | "guildId">
) {
const existing = await prisma.gameArea.findFirst({ where: { key, guildId } });
if (existing)
return prisma.gameArea.update({
where: { id: existing.id },
data: { ...data },
});
return prisma.gameArea.create({ data: { ...data, key, guildId } });
}
async function upsertMob(
guildId: string | null,
key: string,
data: Omit<Prisma.MobUncheckedCreateInput, "key" | "guildId">
) {
const existing = await prisma.mob.findFirst({ where: { key, guildId } });
if (existing)
return prisma.mob.update({ where: { id: existing.id }, data: { ...data } });
return prisma.mob.create({ data: { ...data, key, guildId } });
}
async function upsertItemRecipe(
guildId: string | null,
productKey: string,
ingredients: { itemKey: string; qty: number }[],
productQty = 1
) {
// Ensure product exists
const product = await prisma.economyItem.findFirst({
where: { key: productKey, OR: [{ guildId }, { guildId: null }] },
orderBy: [{ guildId: "desc" }],
});
if (!product) throw new Error(`Product item not found: ${productKey}`);
// Find existing recipe by productItemId
const existing = await prisma.itemRecipe.findUnique({
where: { productItemId: product.id },
});
if (existing) {
// Recreate ingredients set
await prisma.recipeIngredient.deleteMany({
where: { recipeId: existing.id },
});
for (const ing of ingredients) {
const it = await prisma.economyItem.findFirst({
where: { key: ing.itemKey, OR: [{ guildId }, { guildId: null }] },
orderBy: [{ guildId: "desc" }],
});
if (!it) throw new Error(`Ingredient item not found: ${ing.itemKey}`);
await prisma.recipeIngredient.create({
data: { recipeId: existing.id, itemId: it.id, quantity: ing.qty },
});
}
return existing;
}
const r = await prisma.itemRecipe.create({
data: { productItemId: product.id, productQuantity: productQty },
});
for (const ing of ingredients) {
const it = await prisma.economyItem.findFirst({
where: { key: ing.itemKey, OR: [{ guildId }, { guildId: null }] },
orderBy: [{ guildId: "desc" }],
});
if (!it) throw new Error(`Ingredient item not found: ${ing.itemKey}`);
await prisma.recipeIngredient.create({
data: { recipeId: r.id, itemId: it.id, quantity: ing.qty },
});
}
return r;
}
export async function runFullServerSetup(
guildIdArg?: string | null,
options?: { dryRun?: boolean }
) {
const guildId = guildIdArg ?? DEFAULT_GUILD;
console.log("Starting full server setup for guild=", guildId, options ?? {});
// --- Items: tools, weapons, materials ---
await upsertEconomyItem(guildId, "tool.pickaxe.basic", {
name: "Pico Básico",
stackable: false,
props: {
tool: { type: "pickaxe", tier: 1 },
breakable: { enabled: true, maxDurability: 100, durabilityPerUse: 5 },
} as unknown as Prisma.InputJsonValue,
tags: ["tool", "mine"],
});
await upsertEconomyItem(guildId, "tool.pickaxe.iron", {
name: "Pico de Hierro",
stackable: false,
props: {
tool: { type: "pickaxe", tier: 2 },
breakable: { enabled: true, maxDurability: 180, durabilityPerUse: 4 },
} as unknown as Prisma.InputJsonValue,
tags: ["tool", "mine", "tier2"],
});
await upsertEconomyItem(guildId, "weapon.sword.iron", {
name: "Espada de Hierro",
stackable: false,
props: {
damage: 10,
tool: { type: "sword", tier: 1 },
breakable: { enabled: true, maxDurability: 150, durabilityPerUse: 2 },
} as unknown as Prisma.InputJsonValue,
tags: ["weapon"],
});
await upsertEconomyItem(guildId, "armor.leather.basic", {
name: "Armadura de Cuero",
stackable: false,
props: { defense: 3 } as unknown as Prisma.InputJsonValue,
tags: ["armor"],
});
await upsertEconomyItem(guildId, "ore.iron", {
name: "Mineral de Hierro",
stackable: true,
props: { craftingOnly: true } as unknown as Prisma.InputJsonValue,
tags: ["ore", "common"],
});
await upsertEconomyItem(guildId, "ingot.iron", {
name: "Lingote de Hierro",
stackable: true,
props: {} as unknown as Prisma.InputJsonValue,
tags: ["ingot", "metal"],
});
// Consumibles y pociones
await upsertEconomyItem(guildId, "food.meat.small", {
name: "Carne Pequeña",
stackable: true,
props: {
food: { healHp: 8, cooldownSeconds: 20 },
} as unknown as Prisma.InputJsonValue,
tags: ["food"],
});
await upsertEconomyItem(guildId, "potion.energy", {
name: "Poción Energética",
stackable: true,
props: {
potion: { removeEffects: ["FATIGUE"], cooldownSeconds: 90 },
} as unknown as Prisma.InputJsonValue,
tags: ["potion", "utility"],
});
// Cofre con recompensas
await upsertEconomyItem(guildId, "chest.daily", {
name: "Cofre Diario",
stackable: true,
props: {
chest: {
enabled: true,
consumeOnOpen: true,
randomMode: "single",
rewards: [
{ type: "coins", amount: 200 },
{ type: "item", itemKey: "ingot.iron", qty: 2 },
],
},
} as unknown as Prisma.InputJsonValue,
tags: ["chest"],
});
// --- Mutations / enchants catalog ---
// Item mutations (catalog)
const existingRuby = await prisma.itemMutation.findFirst({
where: { key: "ruby_core", guildId },
});
if (existingRuby) {
await prisma.itemMutation.update({
where: { id: existingRuby.id },
data: { name: "Núcleo de Rubí", effects: { damageBonus: 15 } as any },
});
} else {
await prisma.itemMutation.create({
data: {
key: "ruby_core",
name: "Núcleo de Rubí",
guildId,
effects: { damageBonus: 15 } as any,
} as any,
});
}
const existingEmerald = await prisma.itemMutation.findFirst({
where: { key: "emerald_core", guildId },
});
if (existingEmerald) {
await prisma.itemMutation.update({
where: { id: existingEmerald.id },
data: {
name: "Núcleo de Esmeralda",
effects: { defenseBonus: 10, maxHpBonus: 20 } as any,
},
});
} else {
await prisma.itemMutation.create({
data: {
key: "emerald_core",
name: "Núcleo de Esmeralda",
guildId,
effects: { defenseBonus: 10, maxHpBonus: 20 } as any,
} as any,
});
}
// --- Recipes (crafteo): iron_ingot <- iron ore x3
// Create ingredient items if missing
await upsertEconomyItem(guildId, "ingot.iron", {
name: "Lingote de Hierro",
stackable: true,
props: {} as unknown as Prisma.InputJsonValue,
tags: ["ingot"],
});
await upsertEconomyItem(guildId, "ore.iron", {
name: "Mineral de Hierro",
stackable: true,
props: {} as unknown as Prisma.InputJsonValue,
tags: ["ore"],
});
await upsertItemRecipe(
guildId,
"ingot.iron",
[{ itemKey: "ore.iron", qty: 3 }],
1
);
// --- Areas & Levels ---
const mine = await upsertGameArea(guildId, "mine.cavern", {
name: "Mina: Caverna",
type: "MINE",
config: { cooldownSeconds: 10 } as unknown as Prisma.InputJsonValue,
});
await prisma.gameAreaLevel.upsert({
where: { areaId_level: { areaId: mine.id, level: 1 } },
update: {},
create: {
areaId: mine.id,
level: 1,
requirements: {
tool: { required: true, toolType: "pickaxe", minTier: 1 },
} as any,
rewards: {
draws: 2,
table: [
{ type: "item", itemKey: "ore.iron", qty: 2, weight: 70 },
{ type: "coins", amount: 10, weight: 30 },
],
} as any,
mobs: {
draws: 1,
table: [
{ mobKey: "slime.green", weight: 50 },
{ mobKey: "bat", weight: 50 },
],
} as any,
},
});
const lagoon = await upsertGameArea(guildId, "lagoon.shore", {
name: "Laguna: Orilla",
type: "LAGOON",
config: { cooldownSeconds: 12 } as unknown as Prisma.InputJsonValue,
});
await prisma.gameAreaLevel.upsert({
where: { areaId_level: { areaId: lagoon.id, level: 1 } },
update: {},
create: {
areaId: lagoon.id,
level: 1,
requirements: {
tool: { required: true, toolType: "rod", minTier: 1 },
} as any,
rewards: {
draws: 2,
table: [
{ type: "item", itemKey: "food.meat.small", qty: 1, weight: 70 },
{ type: "coins", amount: 10, weight: 30 },
],
} as any,
mobs: { draws: 0, table: [] } as any,
},
});
// --- Basic mobs ---
await upsertMob(guildId, "slime.green", {
name: "Slime Verde",
stats: { attack: 4, hp: 18 } as any,
drops: [{ itemKey: "ingot.iron", qty: 1, weight: 10 }] as any,
});
await upsertMob(guildId, "bat", {
name: "Murciélago",
stats: { attack: 3, hp: 10 } as any,
drops: Prisma.DbNull,
});
// Advanced mobs
await upsertMob(guildId, "goblin", {
name: "Duende",
stats: { attack: 8, hp: 30 } as any,
drops: [{ itemKey: "ore.iron", qty: 1, weight: 50 }] as any,
});
await upsertMob(guildId, "orc", {
name: "Orco",
stats: { attack: 12, hp: 50 } as any,
drops: Prisma.DbNull,
});
// Programar un par de ataques demo (opcional)
const targetUser = process.env.TARGET_USER ?? null;
if (targetUser) {
const slime = await prisma.mob.findFirst({
where: { key: "slime.green", OR: [{ guildId }, { guildId: null }] },
orderBy: [{ guildId: "desc" }],
});
if (slime) {
const now = Date.now();
await prisma.scheduledMobAttack.createMany({
data: [
{
userId: targetUser,
guildId: guildId ?? "global",
mobId: slime.id,
scheduleAt: new Date(now + 5_000),
},
{
userId: targetUser,
guildId: guildId ?? "global",
mobId: slime.id,
scheduleAt: new Date(now + 15_000),
},
],
});
}
}
console.log("Full server setup complete.");
}
// Backwards-compatible CLI entry
if (require.main === module) {
const gid = process.env.GUILD_ID ?? DEFAULT_GUILD;
runFullServerSetup(gid)
.then(() => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});
}

View File

@@ -0,0 +1,72 @@
import fs from "fs";
import { prisma } from "../src/core/database/prisma";
async function run() {
if (!process.env.XATA_DB) {
console.error("XATA_DB not set — aborting");
process.exit(1);
}
if (!fs.existsSync("invalid_mobs_backup.json")) {
console.error("invalid_mobs_backup.json not found — run cleanup first");
process.exit(1);
}
const bak = JSON.parse(fs.readFileSync("invalid_mobs_backup.json", "utf8"));
const ids: string[] = bak.map((b: any) => b.id).filter(Boolean);
if (ids.length === 0) {
console.log("No ids found in invalid_mobs_backup.json");
return;
}
console.log(
"Backing up ScheduledMobAttack rows that reference these mob ids..."
);
try {
const deps = await (prisma as any).scheduledMobAttack.findMany({
where: { mobId: { in: ids } },
});
fs.writeFileSync(
"scheduled_mob_attack_backup.json",
JSON.stringify(deps, null, 2)
);
console.log(
`Backed up ${deps.length} ScheduledMobAttack rows to scheduled_mob_attack_backup.json`
);
if (deps.length > 0) {
console.log(
"Deleting ScheduledMobAttack rows referencing invalid mobs..."
);
const delRes = await (prisma as any).scheduledMobAttack.deleteMany({
where: { mobId: { in: ids } },
});
console.log(`Deleted ${delRes.count || delRes} ScheduledMobAttack rows`);
} else {
console.log("No dependent ScheduledMobAttack rows to delete.");
}
} catch (e: any) {
console.error("Failed to backup/delete dependent rows:", e?.message ?? e);
process.exit(1);
}
console.log("Deleting invalid mob rows from Mob table...");
try {
const delMobs = await (prisma as any).mob.deleteMany({
where: { id: { in: ids } },
});
console.log(`Deleted ${delMobs.count || delMobs} mob rows`);
} catch (e: any) {
console.error("Failed to delete mob rows:", e?.message ?? e);
process.exit(1);
}
console.log(
"Done. Backups: invalid_mobs_backup.json, scheduled_mob_attack_backup.json"
);
}
run().catch((e) => {
console.error(e);
process.exit(1);
});

54
scripts/testMobUnit.ts Normal file
View File

@@ -0,0 +1,54 @@
import assert from "assert";
import {
computeMobStats,
getMobInstance,
MOB_DEFINITIONS,
} from "../src/game/mobs/mobData";
import { createOrUpdateMob } from "../src/game/mobs/admin";
async function run() {
console.log("Running mob unit tests...");
// computeMobStats basic
const def = MOB_DEFINITIONS[0];
const statsLv1 = computeMobStats(def, 1);
assert(typeof statsLv1.hp === "number", "hp should be number");
assert(typeof statsLv1.attack === "number", "attack should be number");
// scaling test
const statsLv5 = computeMobStats(def, 5);
if ((def.scaling && def.scaling.hpPerLevel) || 0) {
assert(statsLv5.hp >= statsLv1.hp, "hp should not decrease with level");
}
console.log("computeMobStats: OK");
// getMobInstance basic
const key = def.key;
const inst = getMobInstance(key, 3);
assert(inst !== null, "getMobInstance should return instance");
assert(inst!.scaled.hp > 0, "instance hp > 0");
console.log("getMobInstance: OK");
// createOrUpdateMob (non-DB mode should return def)
try {
const res = await createOrUpdateMob({
...def,
key: "unit.test.mob",
} as any);
if (!res || !res.def) throw new Error("createOrUpdateMob returned invalid");
console.log("createOrUpdateMob: OK (no-DB mode)");
} catch (e) {
console.warn(
"createOrUpdateMob: skipped (DB may be required) -",
(e as any)?.message ?? e
);
}
console.log("All mob unit tests passed.");
}
run().catch((e) => {
console.error("Tests failed:", e);
process.exit(1);
});

66
scripts/testRewardMods.ts Normal file
View File

@@ -0,0 +1,66 @@
import {
initializeMobRepository,
getMobInstance,
listMobKeys,
} from "../src/game/mobs/mobData";
import { runMinigame } from "../src/game/minigames/service";
import { prisma } from "../src/core/database/prisma";
async function run() {
console.log("Initializing mob repository...");
await initializeMobRepository();
console.log("Available mob keys:", listMobKeys());
// Mock user/guild for smoke (these should exist in your test DB or the functions will create wallet entries etc.)
const userId = "test-user";
const guildId = "test-guild";
try {
console.log("Ensuring minimal game area 'mine.cavern' exists...");
// create minimal area and level if not present
let area = await prisma.gameArea.findFirst({
where: { key: "mine.cavern", OR: [{ guildId }, { guildId: null }] },
orderBy: [{ guildId: "desc" }],
});
if (!area) {
area = await prisma.gameArea.create({
data: {
key: "mine.cavern",
guildId: null,
name: "Cavern of Tests",
type: "MINE",
config: {},
metadata: {},
} as any,
});
}
let lvl = await prisma.gameAreaLevel.findFirst({
where: { areaId: area.id, level: 1 },
});
if (!lvl) {
lvl = await prisma.gameAreaLevel.create({
data: {
areaId: area.id,
level: 1,
requirements: {} as any,
rewards: {
draws: 1,
table: [{ type: "coins", amount: 5, weight: 1 }],
} as any,
mobs: { draws: 0, table: [] } as any,
} as any,
});
}
console.log("Running minigame mine.cavern level 1 as smoke test...");
const res = await runMinigame(userId, guildId, "mine.cavern", 1);
console.log("Minigame result:", JSON.stringify(res, null, 2));
} catch (e) {
console.error("runMinigame failed:", e);
}
}
run().catch((e) => {
console.error(e);
process.exit(1);
});