import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { constantCase, pipelineDirectory, projectPath, projectRoot, readJson, resolveProject, } from "./lib/recipe.mjs"; const catalogFile = path.join(projectRoot, "runewaker-export-work", "all-instances-preparation-report.json"); const outputFile = path.join(projectRoot, "src", "game", "generated", "runewakerDungeonDefinitions.generated.ts"); const availabilityFile = path.join(projectRoot, "src", "game", "generated", "dungeonAvailability.json"); const catalog = await readJson(catalogFile); const dungeons = catalog.dungeons.filter((entry) => entry.slug && entry.status !== "preserved-existing-pilot"); const imports = []; const definitions = []; function n(value) { return Number(Number(value).toFixed(6)); } for (const dungeon of dungeons) { const recipeFile = path.join(pipelineDirectory, "recipes", dungeon.slug + "-population.json"); const recipe = await readJson(recipeFile); const environmentRecipe = await readJson(resolveProject(recipe.files.environmentRecipe)); const metadata = await readJson(resolveProject(recipe.files.environmentMetadata)); const conversion = await readJson(path.join(path.dirname(resolveProject(recipe.files.environmentMetadata)), "conversion-report.json")); const recast = await readJson(path.join(path.dirname(resolveProject(recipe.files.environmentMetadata)), "recast-report.json")); const validation = await readJson(path.join(path.dirname(resolveProject(recipe.files.environmentMetadata)), "instance-validation-report.json")); const snapshot = await readJson(resolveProject(recipe.files.snapshot)); const actorManifest = await readJson(resolveProject(recipe.files.actorManifest)); if (validation.status !== "green") throw new Error(dungeon.slug + " is not green and cannot be registered."); const prefix = constantCase(dungeon.slug); const populationModule = "./" + path.basename(recipe.files.generatedSource, ".ts"); imports.push( "import { " + prefix + "_BOSSES, " + prefix + "_ENTITIES, " + prefix + "_OBJECTIVE_ORDER, " + prefix + "_STATIC_SPAWNS } from " + JSON.stringify(populationModule) + ";", ); const collisionTriangles = new Map(conversion.collision.map((entry) => [entry.file, entry.triangles])); const asset = (entry, index) => ({ id: entry.role === "navigation" ? "navigation" : entry.role + "-" + String(index).padStart(3, "0"), fileName: entry.fileName, checksum: entry.sha256, size: entry.size, triangleCount: entry.role === "visual" ? conversion.visual.triangles : entry.role === "navigation" ? recast.geometry.triangles : collisionTriangles.get(entry.fileName) ?? 0, }); const visual = metadata.assets.filter((entry) => entry.role === "visual").map(asset); const collision = metadata.assets.filter((entry) => entry.role === "collision").map(asset); const navigation = metadata.assets.filter((entry) => entry.role === "navigation").map(asset)[0]; const combatLevels = recipe.templates .filter((entry) => ["combat", "boss"].includes(entry.classification) && Number(entry.level) > 0) .map((entry) => Number(entry.level)); const levelRange = combatLevels.length ? [Math.min(...combatLevels), Math.max(...combatLevels)] : [1, 100]; const bounds = conversion.visual.bounds; const width = Math.max(120, Math.ceil(bounds.max[0] - bounds.min[0])); const height = Math.max(120, Math.ceil(bounds.max[2] - bounds.min[2])); const far = Math.max(220, Math.ceil(Math.hypot(width, height) * 0.85)); const warnings = [ ...environmentRecipe.exceptions, ...((actorManifest.proceduralFallbacks ?? []).length ? [ String(actorManifest.proceduralFallbacks.length) + " runtime/dynamic actor containers use HealerMan procedural visuals because their ROS files expose no static triangles.", ] : []), "Mechanics candidates are source-audited; complex doors, summons, phases, patrol scripts, and loot remain review-required.", ]; const definition = { schemaVersion: "__SCHEMA__", id: dungeon.slug, title: metadata.title, mapId: recipe.zoneId, lfgDungeonIds: [], expansion: "runewaker", environmentMode: "runewaker-ros", difficultyVariants: [{ id: "normal", difficulty: "normal", spawnMask: 1, levelRange }], defaultDifficultyId: "normal", availability: "available", presentation: { location: metadata.title, theme: "RuneWaker dungeon import", summary: "Explore " + metadata.title + " and defeat its source-authored encounters.", loadingMessage: "Entering " + metadata.title + "...", unchartedAreaName: "Uncharted " + metadata.title, background: "#09090c", fog: { color: "#0b0b10", near: 34, far }, ambientLight: { color: "#9d96a7", intensity: 0.2 }, hemisphereLight: { skyColor: "#77768c", groundColor: "#0a0809", intensity: 0.48 }, directionalLight: { color: "#c9bea6", intensity: 0.78, offset: [9, 17, -6] }, materials: { cutoutPatterns: [], cutoutNames: [], blendPatterns: [], blendNames: [], additivePatterns: [], additiveNames: [], }, map: { width, height, padding: 24, contours: [] }, }, assets: { visual, collision, navigation }, transform: { position: [0, 0, 0], rotation: [0, 0, 0], scale: 1, fromRunewaker: "local=scale(0.1)*inverse(WDB placement)*server; three(x,y,z)=(local.x,local.y,-local.z)", }, bounds: { min: bounds.min.map(n), max: bounds.max.map(n) }, entrance: { name: metadata.title + " Entrance", footPosition: metadata.entrance.footPosition.map(n), forward: metadata.entrance.forward.map(n), yaw: n(metadata.entrance.yaw), }, areas: [{ id: "entrance", name: metadata.title + " Entrance", center: metadata.entrance.footPosition.map(n), radius: 24, }], entities: "__ENTITIES__", staticSpawns: "__SPAWNS__", roamingPacks: [], bosses: "__BOSSES__", objectiveOrder: "__OBJECTIVES__", navigationLinks: [], serverGameObjects: [], provenance: { clientBuild: "preserved RuneWaker source snapshot", clientArchiveHashes: { wdb: metadata.source.wdbSha256, primaryRos: metadata.source.modelSha256, populationSnapshot: snapshot.contentSha256, }, databaseAdapter: "portable RuneWaker Zone " + recipe.zoneId + " JSON snapshot", sourceSnapshot: projectPath(resolveProject(recipe.files.snapshot)), coordinateTransform: "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)", }, validation: { status: "green", report: projectPath(path.join(path.dirname(resolveProject(recipe.files.environmentMetadata)), "instance-validation-report.json")), blockers: [], warnings, }, }; let source = JSON.stringify(definition, null, 2) .replace(JSON.stringify("__SCHEMA__"), "DUNGEON_DEFINITION_SCHEMA_VERSION") .replace(JSON.stringify("__ENTITIES__"), prefix + "_ENTITIES") .replace(JSON.stringify("__SPAWNS__"), prefix + "_STATIC_SPAWNS") .replace(JSON.stringify("__BOSSES__"), prefix + "_BOSSES") .replace(JSON.stringify("__OBJECTIVES__"), prefix + "_OBJECTIVE_ORDER"); definitions.push(" " + source.replaceAll("\n", "\n ") + " as const satisfies DungeonDefinition"); } const generated = [ "/* Generated by scripts/runewaker-pipeline/generate-runtime-registry.mjs. */", 'import { DUNGEON_DEFINITION_SCHEMA_VERSION, type DungeonDefinition } from "../dungeonTypes";', ...imports, "", "export const RUNEWAKER_DUNGEON_DEFINITIONS = [", definitions.join(",\n"), "] as const satisfies readonly DungeonDefinition[];", "", ].join("\n"); await writeFile(outputFile, generated, "utf8"); const availability = JSON.parse(await readFile(availabilityFile, "utf8")); for (const dungeon of dungeons) availability.enabled[dungeon.slug] = true; await writeFile(availabilityFile, JSON.stringify(availability, null, 2) + "\n", "utf8"); console.log("[runewaker] registered " + dungeons.length + " generated dungeon definitions."); console.log("[runewaker] source: " + projectPath(outputFile));