import { mkdir, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { exists, optionValue, pipelineDirectory, projectPath, projectRoot, readJson, slugify, } from "./lib/recipe.mjs"; const argv = process.argv.slice(2); const sourceRoot = path.resolve( process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "../rom-pvt-server-backups/Runewaker"), ); const configFile = path.join(sourceRoot, "Tools", "dungeon_config.json"); const wdbDirectory = path.join(sourceRoot, "Resource", "wdb"); const luaDirectory = path.join(sourceRoot, "Resource", "luascript"); const recipeDirectory = path.join(pipelineDirectory, "recipes"); const overrideFile = path.join(pipelineDirectory, "discovery-overrides.json"); const outputFile = path.resolve( projectRoot, optionValue(argv, "--output", "runewaker-export-work/instance-catalog.json"), ); const STOP_WORDS = new Set([ "dgn", "inst", "instance", "the", "of", "and", "a", "an", "dungeon", ]); async function listWdbFiles(directory, prefix = "") { const result = []; for (const entry of await readdir(directory, { withFileTypes: true })) { const relative = prefix ? prefix + "/" + entry.name : entry.name; const absolute = path.join(directory, entry.name); if (entry.isDirectory()) result.push(...await listWdbFiles(absolute, relative)); else if (entry.isFile() && entry.name.toLowerCase().endsWith(".wdb")) result.push(relative); } return result; } function tokens(value) { return slugify(value) .split("-") .filter((token) => token && !STOP_WORDS.has(token)); } function similarity(leftValue, rightValue) { const left = new Set(tokens(leftValue)); const right = new Set(tokens(rightValue)); if (!left.size || !right.size) return 0; let intersection = 0; for (const token of left) if (right.has(token)) intersection += 1; const union = new Set([...left, ...right]).size; const containment = intersection / Math.min(left.size, right.size); return Number((intersection / union * 0.65 + containment * 0.35).toFixed(4)); } async function recipesByDungeon() { const result = new Map(); for (const name of await readdir(recipeDirectory)) { if (!name.endsWith(".json")) continue; const file = path.join(recipeDirectory, name); const recipe = await readJson(file); const dungeonId = recipe.dungeonId ?? recipe.slug; if (!dungeonId) continue; const key = slugify(dungeonId); const entry = result.get(key) ?? { environment: null, population: null }; if (name.includes("population")) entry.population = projectPath(file); else entry.environment = projectPath(file); result.set(key, entry); } return result; } if (!await exists(configFile)) throw new Error("Missing RuneWaker dungeon catalog: " + configFile); if (!await exists(wdbDirectory)) throw new Error("Missing RuneWaker WDB directory: " + wdbDirectory); const config = await readJson(configFile); const overrides = await exists(overrideFile) ? await readJson(overrideFile) : {}; const knownRecipes = await recipesByDungeon(); const wdbFiles = (await listWdbFiles(wdbDirectory)).sort((left, right) => left.localeCompare(right)); const allWdb = new Set(wdbFiles.map((name) => name.toLowerCase())); const dungeons = []; for (const dungeon of config.dungeons ?? []) { const override = overrides[dungeon.id] ?? {}; const ranked = wdbFiles .map((fileName) => ({ fileName, score: Math.max( similarity(dungeon.id, path.parse(fileName).name), similarity(dungeon.name, path.parse(fileName).name), ), })) .filter((candidate) => candidate.score > 0) .sort((left, right) => right.score - left.score || left.fileName.localeCompare(right.fileName)) .slice(0, 5); const selectedWdb = override.wdb ?? (ranked[0]?.score >= 0.62 && ranked[0].score - (ranked[1]?.score ?? 0) >= 0.12 ? ranked[0].fileName : null); const luaFiles = []; for (const name of dungeon.lua_files ?? []) { luaFiles.push({ name, exists: await exists(path.join(luaDirectory, name)) }); } const recipes = knownRecipes.get(slugify(dungeon.id)) ?? { environment: null, population: null }; dungeons.push({ id: dungeon.id, slug: slugify(dungeon.id), name: dungeon.name, baseZone: dungeon.base_zone, copyFromZone: dungeon.copy_from_zone, mobTemplateCount: (dungeon.mob_guids ?? []).length, configuredBossTemplateCount: (dungeon.boss_guids ?? []).length, luaFiles, entryLuaPrefix: dungeon.entry_lua_prefix || null, selectedWdb, selectedWdbExists: selectedWdb ? allWdb.has(selectedWdb.toLowerCase()) : false, wdbCandidates: ranked, recipes, readiness: recipes.environment && recipes.population ? "packaged" : selectedWdb ? "ready-to-scaffold" : "needs-wdb-review", }); } const selected = new Set(dungeons.map((dungeon) => dungeon.selectedWdb?.toLowerCase()).filter(Boolean)); const payload = { schemaVersion: 1, generatedAt: new Date().toISOString(), source: { dungeonConfig: configFile, wdbDirectory, luaDirectory, }, counts: { configuredDungeons: dungeons.length, wdbFiles: wdbFiles.length, packaged: dungeons.filter((dungeon) => dungeon.readiness === "packaged").length, readyToScaffold: dungeons.filter((dungeon) => dungeon.readiness === "ready-to-scaffold").length, needsWdbReview: dungeons.filter((dungeon) => dungeon.readiness === "needs-wdb-review").length, }, dungeons, unmatchedWdbFiles: wdbFiles.filter((name) => !selected.has(name.toLowerCase())), }; await mkdir(path.dirname(outputFile), { recursive: true }); await writeFile(outputFile, JSON.stringify(payload, null, 2) + "\n", "utf8"); console.log("[runewaker] discovered " + payload.counts.configuredDungeons + " configured dungeons and " + payload.counts.wdbFiles + " WDB files."); console.log("[runewaker] " + payload.counts.readyToScaffold + " ready to scaffold; " + payload.counts.needsWdbReview + " need a WDB choice; " + payload.counts.packaged + " already packaged."); console.log("[runewaker] catalog: " + projectPath(outputFile)); export { similarity, tokens };