import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { camelCase, exists, hasFlag, optionValue, pipelineDirectory, projectPath, projectRoot, readJson, slugify, } from "./lib/recipe.mjs"; import { inspectWdb } from "./lib/wdb.mjs"; const argv = process.argv.slice(2); const dungeonId = optionValue(argv, "--id") ?? argv.find((value) => !value.startsWith("--")); if (!dungeonId) { throw new Error("Usage: npm run runewaker:instance -- scaffold --id forsaken_abbey [--wdb file.wdb]"); } const sourceRoot = path.resolve( process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "../rom-pvt-server-backups/Runewaker"), ); const dungeonConfigFile = path.join(sourceRoot, "Tools", "dungeon_config.json"); const config = await readJson(dungeonConfigFile); const dungeon = (config.dungeons ?? []).find((entry) => entry.id === dungeonId); if (!dungeon) throw new Error("Unknown dungeon_config id: " + dungeonId); const overrides = await readJson(path.join(pipelineDirectory, "discovery-overrides.json")); const override = overrides[dungeonId] ?? {}; const slug = slugify(optionValue(argv, "--slug", dungeon.id)); const requestedWdb = optionValue(argv, "--wdb", override.wdb); if (!requestedWdb) { throw new Error( "No unambiguous WDB is known for " + dungeonId + ". Run discovery, review its candidates, then pass --wdb .", ); } const wdbFile = path.isAbsolute(requestedWdb) ? requestedWdb : path.join(sourceRoot, "Resource", "wdb", ...requestedWdb.replaceAll("\\", "/").split("/")); if (!await exists(wdbFile)) throw new Error("WDB does not exist: " + wdbFile); const workRoot = path.join(projectRoot, "runewaker-export-work", slug); const inspection = await inspectWdb({ wdbFile, reportFile: path.join(workRoot, "inspection", "wdb-report.json"), }); const explicitModel = optionValue(argv, "--model"); const recommended = inspection.recommendation; const model = explicitModel ?? (recommended?.confidence === "high" ? recommended.resource : null); if (!model) { throw new Error( "The WDB primary environment is ambiguous. Review " + projectPath(path.join(workRoot, "inspection", "wdb-report.json")) + " and rerun with --model .", ); } function luaNumber(source, name) { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = source.match(new RegExp("^\\s*" + escaped + "\\s*=\\s*(-?\\d+(?:\\.\\d+)?)", "m")); return match ? Number(match[1]) : null; } async function discoverEntry() { const candidates = [ dungeon.entry_lua_prefix ? dungeon.entry_lua_prefix + "_Z" + dungeon.base_zone : null, dungeon.entry_lua_prefix || null, "DQ_Z" + dungeon.base_zone, ].filter(Boolean); for (const fileName of dungeon.lua_files ?? []) { const absolute = path.join(sourceRoot, "Resource", "luascript", fileName); if (!await exists(absolute)) continue; const source = await readFile(absolute, "utf8"); for (const prefix of candidates) { const values = ["X", "Y", "Z", "DIR"].map((suffix) => luaNumber(source, prefix + "_" + suffix)); if (values.every(Number.isFinite)) { return { entryLua: "Resource/luascript/" + fileName, entryConstantPrefix: prefix, values, reviewRequired: false, }; } } } const spawn = dungeon.player_spawn ?? {}; return { entryLua: null, entryConstantPrefix: null, values: [spawn.x, spawn.y, spawn.z, spawn.dir].map((value) => Number(value ?? 0)), reviewRequired: true, }; } const entry = await discoverEntry(); const camel = camelCase(slug); const environmentRecipeFile = path.join(pipelineDirectory, "recipes", slug + ".json"); const populationRecipeFile = path.join(pipelineDirectory, "recipes", slug + "-population.json"); for (const file of [environmentRecipeFile, populationRecipeFile]) { if (await exists(file) && !hasFlag(argv, "--force")) { throw new Error("Recipe already exists; refusing to overwrite without --force: " + projectPath(file)); } } const environmentRecipe = { schemaVersion: 2, slug, title: dungeon.name, source: { rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker", resourceRoot: "Resource", wdb: "Resource/wdb/" + path.relative(path.join(sourceRoot, "Resource", "wdb"), wdbFile).split(path.sep).join("/"), model: model.replaceAll("\\", "/"), dungeonConfig: "Tools/dungeon_config.json", dungeonConfigId: dungeon.id, ...(entry.entryLua ? { entryLua: entry.entryLua, entryConstantPrefix: entry.entryConstantPrefix, } : { entryCoordinates: { position: entry.values.slice(0, 3), direction: entry.values[3], reviewRequired: true, }, }), }, coordinateSystem: { source: "left-handed Y-up", nativeBridge: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)", metersPerSourceUnit: 0.1, directionUnitsPerTurn: 360, calibration: "model/character/pc/dummy/fake_pc.ros mesh height is 16.4977 source units", }, navigation: { cellSize: 0.3, cellHeight: 0.1, agentHeight: 1.8, agentRadius: 0.36, agentMaxClimb: 0.5, agentMaxSlope: 50, tileSize: 64, retainTileIntermediates: false, }, review: { environment: "source-selected-review-required", collision: "required", entrance: entry.reviewRequired ? "required" : "source-found-review-required", bossAnchors: "required", wdbPlacements: recommended?.confidence === "high" ? "source-selected" : "manual-selection", }, exceptions: [ "Secondary WDB doodads/effects are audited but the first environment pass exports the primary ROS hierarchy.", "Collision initially includes decorative primary-ROS geometry and requires visual/navmesh review.", "Mechanics candidates are extracted from Lua/AutoPlot metadata but require explicit HealerMan mappings.", ], }; const templateIds = [...new Set(dungeon.mob_guids ?? [])].sort((left, right) => left - right); const populationRecipe = { schemaVersion: 2, dungeonId: slug, sourceDungeonConfigId: dungeon.id, zoneId: dungeon.base_zone, expectedActiveRows: null, expectedBossSpawns: null, runtimeIdPrefix: "rw-" + slug, coordinateSystem: { metersPerSourceUnit: 0.1, directionUnitsPerTurn: 360, maximumProjectionDistance: 2, }, files: { environmentRecipe: projectPath(environmentRecipeFile), snapshot: "scripts/runewaker-pipeline/snapshots/" + slug + "-zone-" + dungeon.base_zone + ".json", generatedSource: "src/game/generated/" + camel + "Population.generated.ts", report: "src/assets/game/dungeons/" + slug + "/population-import-report.json", mechanicsReport: "src/assets/game/dungeons/" + slug + "/mechanics-audit.json", mechanicsSource: "src/game/generated/" + camel + "Mechanics.generated.ts", environmentMetadata: "src/assets/game/dungeons/" + slug + "/import-metadata.json", navigation: "src/assets/game/dungeons/" + slug + "/" + slug + "-navigation.glb", actorManifest: "public/assets/creatures/" + slug + "/manifest.json", actorOutput: "public/assets/creatures/" + slug, }, source: { rootEnvironmentVariable: "RUNEWAKER_ROOT", relativeDefault: "../rom-pvt-server-backups/Runewaker", resourceRoot: "Resource", dungeonConfig: "Tools/dungeon_config.json", dungeonConfigId: dungeon.id, luaRoot: "Resource/luascript", catalogs: [ "Resource/data/npcobject.db", "Resource/data/imageobject.db", "Resource/data/string_enus.db", ], backups: { global: "../rom-pvt-server-backups/OneForAll/SQL/databases/ROM_Global.bak", objects: "../rom-pvt-server-backups/OneForAll/SQL/databases/ObjectEdit.bak", }, serverEnvironmentVariable: "RUNEWAKER_SQL_SERVER", serverDefault: ".\\HEALERMAN_RW", }, actors: [], templates: templateIds.map((id) => ({ id, name: "REVIEW template " + id, level: 0, classification: "unclassified", actor: null, review: "Run forensic refresh --update-recipe, then review classification and model mapping.", })), deferred: [ "Review Lua/AutoPlot mechanics audit before enabling the dungeon.", "Review doors, summons, patrols, phases, interactables, loot, and original animations.", ], }; const checklist = `# ${dungeon.name} import checklist - [ ] Review primary environment ROS: '${environmentRecipe.source.model}' - [ ] Review entrance${entry.reviewRequired ? " (no authoritative Lua constants were found)" : ""} - [ ] Run the forensic refresh with '--update-recipe' - [ ] Review every generated template classification and actor model - [ ] Build and visually validate environment, collision, and navmesh - [ ] Build actors and static population with SQL stopped - [ ] Review 'mechanics-audit.json'; explicitly map doors, phases, summons, patrols, spells, and loot - [ ] Register the reviewed DungeonDefinition and add catalog/tests - [ ] Run Khronos validation, tests, production build, and a browser playtest The runtime package must remain independent of SQL Server and the preserved RuneWaker folders. `; if (!hasFlag(argv, "--dry-run")) { await mkdir(path.dirname(environmentRecipeFile), { recursive: true }); await mkdir(workRoot, { recursive: true }); await writeFile(environmentRecipeFile, JSON.stringify(environmentRecipe, null, 2) + "\n", "utf8"); await writeFile(populationRecipeFile, JSON.stringify(populationRecipe, null, 2) + "\n", "utf8"); await writeFile(path.join(workRoot, "IMPORT_CHECKLIST.md"), checklist, "utf8"); } console.log("\n[runewaker] scaffolded " + dungeon.name + ":"); console.log(" environment: " + projectPath(environmentRecipeFile)); console.log(" population: " + projectPath(populationRecipeFile)); console.log(" checklist: " + projectPath(path.join(workRoot, "IMPORT_CHECKLIST.md"))); console.log("[runewaker] recipes remain review-required until template/model and mechanics audits are complete.");