Files
2026-08-14 15:56:39 -04:00

170 lines
8.0 KiB
JavaScript

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));