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

251 lines
9.8 KiB
JavaScript

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 <file>.",
);
}
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 <resource.ros>.",
);
}
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.");